Skip to content

feat: openrouter provider - #2884

Closed
seefs001 wants to merge 5363 commits into
QuantumNous:mainfrom
seefs001:feature/openrouter-provider
Closed

feat: openrouter provider#2884
seefs001 wants to merge 5363 commits into
QuantumNous:mainfrom
seefs001:feature/openrouter-provider

Conversation

@seefs001

@seefs001 seefs001 commented Feb 7, 2026

Copy link
Copy Markdown
Collaborator

独立openrouter provider,支持原生 /v1/messages
#2822
暂时不合并,根据 #2791 反馈,OpenRouter的claude计费usage和官方不一致,需要进一步确认。

Summary by CodeRabbit

  • New Features

    • Complete OpenRouter channel integration with comprehensive request/response handling.
    • Enhanced reasoning and thinking capabilities for Claude models with configurable effort levels.
  • Refactor

    • Reorganized internal data structures for OpenRouter integration for improved consistency and maintainability.
    • Improved pointer-based field handling across channel adapters for more robust data processing.

dahetaoa and others added 30 commits February 4, 2026 21:43
…al-usage

fix: charge local input tokens when Gemini returns empty response
- Remove claude-instant-1.2, claude-2, claude-2.0, claude-2.1 from model lists
- Remove /v1/complete endpoint support (legacy completion API)
- Remove RequestModeCompletion and related code paths
- Simplify handler functions by removing requestMode parameter
- Update all channel adaptors that referenced claude handlers
remove: drop support for claude-2 and claude-1 series models
- Introduce Provider interface pattern for standard OAuth protocols
- Create unified controller/oauth.go with common OAuth logic
- Add OAuthError type for translatable error messages
- Add i18n keys and translations (zh/en) for OAuth messages
- Use common.ApiErrorI18n/ApiSuccessI18n for consistent responses
- Preserve backward compatibility for existing routes and data
- Add support for custom OAuth providers, including creation, retrieval, updating, and deletion.
- Introduce new model and controller for managing custom OAuth providers.
- Enhance existing OAuth logic to accommodate custom providers.
- Update API routes for custom OAuth provider management.
- Include i18n support for custom OAuth-related messages.
…th user creation and binding

- Improve error handling in DeleteCustomOAuthProvider to log and return errors when fetching binding counts.
- Refactor user creation and OAuth binding logic to use transactions for atomic operations, ensuring data integrity.
- Add unique constraints to UserOAuthBinding model to prevent duplicate bindings.
- Enhance GitHub OAuth provider error logging for non-200 responses.
- Update AccountManagement component to provide clearer error messages on API failures.
…ers for optional fields

- Change fields in UpdateCustomOAuthProviderRequest struct to use pointers for optional values, allowing for better handling of nil cases.
- Update UpdateCustomOAuthProvider function to check for nil before assigning optional fields, ensuring existing values are preserved when not provided.
…al file types for LF normalization and binary detection
Mitigate XSS vulnerabilities in the playground where AI-generated content
is rendered without sanitization, allowing potential script injection via
prompt injection attacks.

MarkdownRenderer.jsx:
- Replace dangerouslySetInnerHTML with a sandboxed iframe for HTML preview
- Use sandbox="allow-same-origin" to block script execution while allowing
  CSS rendering and iframe height auto-sizing
- Add SandboxedHtmlPreview component with automatic height adjustment

CodeViewer.jsx:
- Add escapeHtml() utility to encode HTML entities before rendering
- Rewrite highlightJson() to process tokens iteratively, escaping each
  token and structural text before wrapping in syntax highlighting spans
- Escape non-JSON and very-large content paths that previously bypassed
  sanitization
- Update linkRegex to correctly match URLs containing & entities

These changes only affect the playground (AI output rendering). Admin-
configured content (home page, about page, footer, notices) remains
unaffected as they use separate code paths and are within the trusted
admin boundary.
🔒 fix(security): sanitize AI-generated HTML to prevent XSS in playground
seefs001 and others added 26 commits March 2, 2026 19:40
…ty-text

Revert "fix: aws text content blocks must be non-empty"
fix: change token model_limits column from varchar(1024) to text
Return error when model price/ratio unset
* feat: add upstream model update detection with scheduled sync and manual apply flows

* feat: support upstream model removal sync and selectable deletes in update modal

* feat: add detect-only upstream updates and show compact +/- model badges

* feat: improve upstream model update UX

* feat: improve upstream model update UX

* fix: respect model_mapping in upstream update detection

* feat: improve upstream update modal to prevent missed add/remove actions

* feat: add admin upstream model update notifications with digest and truncation

* fix: avoid repeated partial-submit confirmation in upstream update modal

* feat: improve ui/ux

* feat: suppress upstream update alerts for unchanged channel-count within 24h

* fix: submit upstream update choices even when no models are selected

* feat: improve upstream model update flow and split frontend updater

* fix merge conflict
…5c19f0556e4655bc

fix: update task billing log content to include reason

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
relay/channel/openrouter/adaptor.go (3)

160-161: ⚠️ Potential issue | 🟠 Major

Use common.Unmarshal for THINKING decode.

This path directly calls json.Unmarshal; use the project JSON wrapper for consistency and policy compliance.

Suggested fix
-		if err := json.Unmarshal(request.THINKING, &thinking); err != nil {
+		if err := common.Unmarshal(request.THINKING, &thinking); err != nil {
 			return nil, fmt.Errorf("error Unmarshal thinking: %w", err)
 		}

As per coding guidelines **/*.go: All JSON marshal/unmarshal operations MUST use wrapper functions from common/json.go, and business code must not directly call encoding/json.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/openrouter/adaptor.go` around lines 160 - 161, Replace the
direct call to json.Unmarshal when decoding request.THINKING with the project's
JSON wrapper: call common.Unmarshal(request.THINKING, &thinking) instead of
json.Unmarshal to comply with the coding guideline; update the error message in
the surrounding function (the THINKING decode block in adaptor.go) to
wrap/return the error from common.Unmarshal (e.g., fmt.Errorf("error unmarshal
thinking: %w", err)) so the code references common.Unmarshal from common/json.go
rather than encoding/json.

183-212: ⚠️ Potential issue | 🟠 Major

Reasoning-model detection is too broad on provider-qualified names.

Checking strings.HasPrefix(info.UpstreamModelName, "o") matches provider prefixes (e.g. openai/...) and can apply reasoning-model mutations to non-reasoning models.

Suggested fix
-	if strings.HasPrefix(info.UpstreamModelName, "o") || strings.HasPrefix(info.UpstreamModelName, "gpt-5") {
+	modelPart := info.UpstreamModelName
+	if idx := strings.LastIndex(modelPart, "/"); idx >= 0 {
+		modelPart = modelPart[idx+1:]
+	}
+	isOReasoningModel := strings.HasPrefix(modelPart, "o1-") || strings.HasPrefix(modelPart, "o3-") || strings.HasPrefix(modelPart, "o4-")
+	isGpt5 := strings.HasPrefix(modelPart, "gpt-5")
+	if isOReasoningModel || isGpt5 {
 		...
-		if strings.HasPrefix(info.UpstreamModelName, "o") {
+		if isOReasoningModel {
 			request.Temperature = nil
 		}
-		if strings.HasPrefix(info.UpstreamModelName, "gpt-5") {
+		if isGpt5 {
 			request.Temperature = nil
 			request.TopP = nil
 			request.LogProbs = nil
 		}
 		...
-		if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") {
+		if !strings.HasPrefix(modelPart, "o1-mini") && !strings.HasPrefix(modelPart, "o1-preview") {
 			if len(request.Messages) > 0 && request.Messages[0].Role == "system" {
 				request.Messages[0].Role = "developer"
 			}
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/openrouter/adaptor.go` around lines 183 - 212, The prefix
checks on info.UpstreamModelName (e.g.,
strings.HasPrefix(info.UpstreamModelName, "o")) are matching provider-qualified
names like "openai/..." and causing incorrect reasoning-model mutations; fix by
extracting the base model name (the substring after the last '/' or final
segment) into a local variable (e.g., baseModel :=
strings.Split(info.UpstreamModelName, "/")[len-1]) and use baseModel for all
HasPrefix checks and for parseReasoningEffortFromModelSuffix; when you update
request.Model or info.UpstreamModelName preserve or recombine the original
provider prefix if needed (or set to the originModel only for the base part) so
other code that expects the provider-qualified name still works, and ensure
request.ReasoningEffort is set from the parsed baseModel result.

128-177: ⚠️ Potential issue | 🟠 Major

ReasoningEffort is cleared too early, which drops valid intent.

Current ordering loses effort context before THINKING conversion and also fails to emit reasoning config for some valid effort cases.

Suggested ordering fix
+	reasoningEffort := request.ReasoningEffort
+
 	if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) && strings.HasSuffix(info.UpstreamModelName, "-thinking") {
 		...
 		if len(request.Reasoning) == 0 {
 			reasoning := map[string]any{"enabled": true}
-			if request.ReasoningEffort != "" && request.ReasoningEffort != "none" {
-				reasoning["effort"] = request.ReasoningEffort
+			if reasoningEffort != "" {
+				reasoning["effort"] = reasoningEffort
 			}
 			...
 		}
-		request.ReasoningEffort = ""
 	} else {
-		if len(request.Reasoning) == 0 && request.ReasoningEffort != "" {
+		if len(request.Reasoning) == 0 && reasoningEffort != "" {
 			reasoning := map[string]any{"enabled": true}
-			if request.ReasoningEffort != "none" {
-				reasoning["effort"] = request.ReasoningEffort
-				marshal, err := common.Marshal(reasoning)
-				if err != nil {
-					return nil, fmt.Errorf("error marshalling reasoning: %w", err)
-				}
-				request.Reasoning = marshal
-			}
+			reasoning["effort"] = reasoningEffort
+			marshal, err := common.Marshal(reasoning)
+			if err != nil {
+				return nil, fmt.Errorf("error marshalling reasoning: %w", err)
+			}
+			request.Reasoning = marshal
 		}
-		request.ReasoningEffort = ""
 	}
 
 	if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") {
 		...
 		reasoning := dto.OpenRouterRequestReasoning{
 			Enabled:   true,
 			MaxTokens: *thinking.BudgetTokens,
+			Effort:    reasoningEffort,
 		}
 		...
 	}
+	request.ReasoningEffort = ""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/openrouter/adaptor.go` around lines 128 - 177, The code clears
request.ReasoningEffort too early which loses intent before the THINKING ->
Reasoning conversion; move the request.ReasoningEffort = "" assignment so it
executes only after all THINKING-handling and reasoning-population logic (i.e.,
after the block that unmarshals dto.Thinking and sets request.Reasoning), and
ensure both branches that create reasoning (the "-thinking" trim branch and the
else branch, plus the anthropic THINKING branch that produces
dto.OpenRouterRequestReasoning) consult request.ReasoningEffort while building
the reasoning payload when request.Reasoning is empty so valid effort values are
preserved and emitted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@dto/openrouter.go`:
- Around line 5-10: Change the optional scalar fields on the
OpenRouterRequestReasoning struct to pointer types so explicit client values are
preserved: make Effort a *string, MaxTokens a *int, and Exclude a *bool (keep
`omitempty` tags). Update any call sites that construct or read
OpenRouterRequestReasoning (creators, unmarshallers, or code that inspects
fields) to handle nil pointers (nil means absent) and dereference safely when
using values. Ensure any JSON re-marshal paths rely on the new pointer semantics
so false/0 are serialized when explicitly provided.

In `@service/convert.go`:
- Around line 36-63: The new OpenRouter conversion block is calling json.Marshal
directly; replace those calls with the project wrapper common.Marshal and
propagate errors the same way. Specifically, in the isOpenRouter branch where
effortBytes := json.Marshal(effort) (used to set openAIRequest.Verbosity) and
where reasoningJSON := json.Marshal(reasoning) (used to set
openAIRequest.Reasoning), call common.Marshal(effort) and
common.Marshal(reasoning) instead and keep the existing error handling
(returning fmt.Errorf("failed to marshal ...: %w", err)); the change applies
around claudeRequest.GetEfforts, claudeRequest.Thinking handling, and
dto.OpenRouterRequestReasoning construction.

---

Duplicate comments:
In `@relay/channel/openrouter/adaptor.go`:
- Around line 160-161: Replace the direct call to json.Unmarshal when decoding
request.THINKING with the project's JSON wrapper: call
common.Unmarshal(request.THINKING, &thinking) instead of json.Unmarshal to
comply with the coding guideline; update the error message in the surrounding
function (the THINKING decode block in adaptor.go) to wrap/return the error from
common.Unmarshal (e.g., fmt.Errorf("error unmarshal thinking: %w", err)) so the
code references common.Unmarshal from common/json.go rather than encoding/json.
- Around line 183-212: The prefix checks on info.UpstreamModelName (e.g.,
strings.HasPrefix(info.UpstreamModelName, "o")) are matching provider-qualified
names like "openai/..." and causing incorrect reasoning-model mutations; fix by
extracting the base model name (the substring after the last '/' or final
segment) into a local variable (e.g., baseModel :=
strings.Split(info.UpstreamModelName, "/")[len-1]) and use baseModel for all
HasPrefix checks and for parseReasoningEffortFromModelSuffix; when you update
request.Model or info.UpstreamModelName preserve or recombine the original
provider prefix if needed (or set to the originModel only for the base part) so
other code that expects the provider-qualified name still works, and ensure
request.ReasoningEffort is set from the parsed baseModel result.
- Around line 128-177: The code clears request.ReasoningEffort too early which
loses intent before the THINKING -> Reasoning conversion; move the
request.ReasoningEffort = "" assignment so it executes only after all
THINKING-handling and reasoning-population logic (i.e., after the block that
unmarshals dto.Thinking and sets request.Reasoning), and ensure both branches
that create reasoning (the "-thinking" trim branch and the else branch, plus the
anthropic THINKING branch that produces dto.OpenRouterRequestReasoning) consult
request.ReasoningEffort while building the reasoning payload when
request.Reasoning is empty so valid effort values are preserved and emitted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9e4f07f0-f0e0-429a-839c-21bd314831eb

📥 Commits

Reviewing files that changed from the base of the PR and between b4a8943 and bef0375.

📒 Files selected for processing (4)
  • dto/claude.go
  • dto/openrouter.go
  • relay/channel/openrouter/adaptor.go
  • service/convert.go

Comment thread dto/openrouter.go
Comment on lines +5 to +10
type OpenRouterRequestReasoning struct {
Enabled bool `json:"enabled"`
Effort string `json:"effort,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Exclude bool `json:"exclude,omitempty"`
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Optional relay scalars should be pointers to preserve explicit values.

Effort, MaxTokens, and Exclude are optional request scalars but non-pointer, so explicit client values like false / 0 can be lost on re-marshal.

Suggested DTO fix
 type OpenRouterRequestReasoning struct {
 	Enabled   bool   `json:"enabled"`
-	Effort    string `json:"effort,omitempty"`
-	MaxTokens int    `json:"max_tokens,omitempty"`
-	Exclude   bool   `json:"exclude,omitempty"`
+	Effort    *string `json:"effort,omitempty"`
+	MaxTokens *int    `json:"max_tokens,omitempty"`
+	Exclude   *bool   `json:"exclude,omitempty"`
 }

As per coding guidelines dto/**/*.go: For upstream relay request DTOs parsed from client JSON and re-marshaled to providers, optional scalar fields MUST use pointer types with omitempty.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type OpenRouterRequestReasoning struct {
Enabled bool `json:"enabled"`
Effort string `json:"effort,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Exclude bool `json:"exclude,omitempty"`
}
type OpenRouterRequestReasoning struct {
Enabled bool `json:"enabled"`
Effort *string `json:"effort,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Exclude *bool `json:"exclude,omitempty"`
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dto/openrouter.go` around lines 5 - 10, Change the optional scalar fields on
the OpenRouterRequestReasoning struct to pointer types so explicit client values
are preserved: make Effort a *string, MaxTokens a *int, and Exclude a *bool
(keep `omitempty` tags). Update any call sites that construct or read
OpenRouterRequestReasoning (creators, unmarshallers, or code that inspects
fields) to handle nil pointers (nil means absent) and dereference safely when
using values. Ensure any JSON re-marshal paths rely on the new pointer semantics
so false/0 are serialized when explicitly provided.

Comment thread service/convert.go
Comment on lines +36 to +63
if isOpenRouter {
if effort := claudeRequest.GetEfforts(); effort != "" {
effortBytes, err := json.Marshal(effort)
if err != nil {
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
return nil, fmt.Errorf("failed to marshal verbosity: %w", err)
}
openAIRequest.Reasoning = reasoningJSON
} else {
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
openAIRequest.Verbosity = effortBytes
}
if claudeRequest.Thinking != nil {
var reasoning *dto.OpenRouterRequestReasoning
switch claudeRequest.Thinking.Type {
case "enabled":
reasoning = &dto.OpenRouterRequestReasoning{
Enabled: true,
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
}
case "adaptive":
reasoning = &dto.OpenRouterRequestReasoning{
Enabled: true,
}
}
if reasoning != nil {
reasoningJSON, err := json.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
}
openAIRequest.Reasoning = reasoningJSON
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -name "service" -type d 2>/dev/null | head -20

Repository: QuantumNous/new-api

Length of output: 45


🏁 Script executed:

# First, let's find the file and inspect it
find . -name "convert.go" -path "*/service/*" 2>/dev/null

Repository: QuantumNous/new-api

Length of output: 83


🏁 Script executed:

# List files in service directory to understand structure
git ls-files | grep "^service/" | head -20

Repository: QuantumNous/new-api

Length of output: 581


🏁 Script executed:

# Read the service/convert.go file to check lines 36-63
wc -l service/convert.go

Repository: QuantumNous/new-api

Length of output: 85


🏁 Script executed:

# Get the imports and the code section around lines 36-63
head -80 service/convert.go | cat -n

Repository: QuantumNous/new-api

Length of output: 3024


🏁 Script executed:

# Check if common/json.go exists and contains the Marshal function
ls -la common/json.go 2>/dev/null || echo "File not found"

Repository: QuantumNous/new-api

Length of output: 125


🏁 Script executed:

# Search for the Marshal function in common package
rg "func.*Marshal\(" common/ --type go

Repository: QuantumNous/new-api

Length of output: 115


🏁 Script executed:

# Read the common/json.go file to see available wrapper functions
cat -n common/json.go

Repository: QuantumNous/new-api

Length of output: 1132


Use common.Marshal in this new OpenRouter conversion block.

Lines 38 and 58 use json.Marshal directly; these should use the project wrapper function. The common package is already imported in the file.

Suggested fix
-			effortBytes, err := json.Marshal(effort)
+			effortBytes, err := common.Marshal(effort)
 			if err != nil {
 				return nil, fmt.Errorf("failed to marshal verbosity: %w", err)
 			}
 			openAIRequest.Verbosity = effortBytes
 		}
 		...
-				reasoningJSON, err := json.Marshal(reasoning)
+				reasoningJSON, err := common.Marshal(reasoning)
 				if err != nil {
 					return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
 				}
 				openAIRequest.Reasoning = reasoningJSON
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/convert.go` around lines 36 - 63, The new OpenRouter conversion block
is calling json.Marshal directly; replace those calls with the project wrapper
common.Marshal and propagate errors the same way. Specifically, in the
isOpenRouter branch where effortBytes := json.Marshal(effort) (used to set
openAIRequest.Verbosity) and where reasoningJSON := json.Marshal(reasoning)
(used to set openAIRequest.Reasoning), call common.Marshal(effort) and
common.Marshal(reasoning) instead and keep the existing error handling
(returning fmt.Errorf("failed to marshal ...: %w", err)); the change applies
around claudeRequest.GetEfforts, claudeRequest.Thinking handling, and
dto.OpenRouterRequestReasoning construction.

@seefs001
seefs001 marked this pull request as draft March 5, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Openrouter支持/v1/messages和/v1/responses 原生端点