Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "coderelay",
"version": "0.1.13",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
4 changes: 2 additions & 2 deletions sidecars/coderelay-proxy/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
original MIT license and copyright notice are retained in `LICENSE`.

The outer `coderelay-proxy` package contains the CodeBuddy CN relay, request
policy, account selection, model synchronization, vision routing and the
newline-delimited JSON lifecycle event protocol consumed by the Tauri host.
policy, account selection, model synchronization and the newline-delimited JSON
lifecycle event protocol consumed by the Tauri host.

The sidecar accepts:

Expand Down
13 changes: 0 additions & 13 deletions sidecars/coderelay-proxy/codebuddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,6 @@ func equalStringSlices(a, b []string) bool {
return true
}

// visionProxyEnabled reports whether the vision-proxy layer is active (mode is
// "routing" or "preprocess"). Used by /v1/models to report `input_modalities:
// ["text","image"]` for non-vision models that the proxy will transparently
// handle — otherwise clients (e.g. Cursor) filter image inputs client-side and
// the image never reaches the relay.
func (m *manifest) visionProxyEnabled() bool {
if m == nil {
return false
}
mode := strings.ToLower(strings.TrimSpace(m.VisionMode))
return mode == "routing" || mode == "preprocess" || mode == "agentic"
}

// modelIDs returns a snapshot of the current model ID list. It is safe for
// concurrent use with setModelIDs.
func (m *manifest) modelIDs() []string {
Expand Down
2 changes: 1 addition & 1 deletion sidecars/coderelay-proxy/codebuddy_model_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const codebuddyModelCacheFilename = "codebuddy_models_cache.json"
// codebuddyModelCache is the persisted form of the CodeBuddy model catalog.
// Models are stored in full (not just IDs) so capability fields such as
// SupportsImages / ContextLength / MaxCompletionTokens survive a reload, which
// vision-proxy routing and max_tokens clamping depend on.
// max_tokens clamping and image-capability checks depend on.
type codebuddyModelCache struct {
Version int `json:"version"`
SyncedAt string `json:"syncedAt,omitempty"`
Expand Down
70 changes: 14 additions & 56 deletions sidecars/coderelay-proxy/manifest_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,6 @@
streamIdleTimeout = 60 * time.Second
imageStreamOpenTimeout = 10 * time.Second
imageStreamIdleTimeout = 60 * time.Second
// visionAgenticStreamIdleTimeout covers the codebuddy vision sub-agent
// loop: up to several rounds of text-model + vision-model upstream calls
// (each 10-30s), so the relay idle watchdog does not trip mid-loop.
visionAgenticStreamIdleTimeout = 300 * time.Second
)

type accountModelRule struct {
Expand All @@ -102,8 +98,6 @@
ImmediateSSEResponse bool `json:"immediateSseResponse"`
MaxConcurrentImageRequests int `json:"maxConcurrentImageRequests"`
DebugLogs *bool `json:"debugLogs,omitempty"`
VisionMode string `json:"visionMode"`
VisionModel string `json:"visionModel"`

apiKeyByValue map[string]*apiKeySpec
accountByID map[string]*accountSpec
Expand Down Expand Up @@ -397,7 +391,6 @@
Path string `json:"path,omitempty"`
RequestKind string `json:"requestKind,omitempty"`
Model string `json:"model,omitempty"`
VisionSubagent bool `json:"visionSubagent,omitempty"`
APIKeyID string `json:"apiKeyId,omitempty"`
APIKeyLabel string `json:"apiKeyLabel,omitempty"`
Transport string `json:"transport,omitempty"`
Expand Down Expand Up @@ -749,7 +742,7 @@
}
e.mu.Lock()
defer e.mu.Unlock()
fmt.Println(string(data))

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

Sensitive data returned by an access to APIKeyID
flows to a logging call.
Sensitive data returned by an access to APIKeyLabel
flows to a logging call.
Sensitive data returned by an access to APIKeyID flows to a logging call.
Sensitive data returned by an access to APIKeyLabel flows to a logging call.
Sensitive data returned by an access to APIKeyID flows to a logging call.
Sensitive data returned by an access to APIKeyLabel flows to a logging call.
Sensitive data returned by an access to APIKeyID flows to a logging call.
Sensitive data returned by an access to APIKeyLabel flows to a logging call.
Sensitive data returned by an access to APIKeyID flows to a logging call.
Sensitive data returned by an access to APIKeyLabel flows to a logging call.
Sensitive data returned by an access to APIKeyID flows to a logging call.
Sensitive data returned by an access to APIKeyLabel flows to a logging call.
Sensitive data returned by an access to APIKeyID flows to a logging call.
Sensitive data returned by an access to APIKeyLabel flows to a logging call.
Sensitive data returned by an access to APIKeyID flows to a logging call.
Sensitive data returned by an access to APIKeyLabel flows to a logging call.
Sensitive data returned by an access to APIKeyID flows to a logging call.
Sensitive data returned by an access to APIKeyLabel flows to a logging call.
Sensitive data returned by an access to APIKeyID flows to a logging call.
Sensitive data returned by an access to APIKeyLabel flows to a logging call.
Sensitive data returned by an access to APIKeyID flows to a logging call.
Sensitive data returned by an access to APIKeyLabel flows to a logging call.
}

func (e *eventEmitter) emitStartupStage(stage string) {
Expand Down Expand Up @@ -979,7 +972,7 @@
if isCodexClientModelsRequest(c.Request) {
c.JSON(http.StatusOK, buildCodexClientModelsResponse(models, spec, contextWindowsForAPIKey(p.manifest, spec)))
} else {
c.JSON(http.StatusOK, buildModelsResponse(models, p.manifest.visionProxyEnabled()))
c.JSON(http.StatusOK, buildModelsResponse(models))
}
c.Abort()
return
Expand Down Expand Up @@ -1138,13 +1131,12 @@
RequestID: requestID,
Method: c.Request.Method,
Path: requestPath(c.Request),
RequestKind: requestKind,
Model: model,
VisionSubagent: internallogging.GetVisionSubagent(c.Request.Context()),
APIKeyID: stringFromAPIKey(spec, "id"),
APIKeyLabel: stringFromAPIKey(spec, "label"),
Transport: diagnosticTransport(c.Request),
Status: status,
RequestKind: requestKind,
Model: model,
APIKeyID: stringFromAPIKey(spec, "id"),
APIKeyLabel: stringFromAPIKey(spec, "label"),
Transport: diagnosticTransport(c.Request),
Status: status,
LatencyMS: latencyMS,
CompletedAtMS: completedAtMS,
Aborted: c.IsAborted(),
Expand Down Expand Up @@ -1248,15 +1240,16 @@
// carries `input_modalities` so clients (e.g. Cursor) can detect vision-capable
// models instead of defaulting to text-only and filtering images client-side.
//
// A model reports image capability when either:
// 1. the backend natively supports images for it (registry.CodebuddyModelSupportsImages), or
// 2. the vision-proxy layer is active and will transparently describe/handle
// images for it (preprocess/routing via hy3-preview).
func buildModelsResponse(models []string, visionProxyEnabled bool) gin.H {
// A model reports image capability when the backend natively supports images
// for it (registry.CodebuddyModelSupportsImages, backed by the online catalog
// plus the measured capability overrides in the registry). Models without
// native image support report text-only so clients do not send images the
// upstream would reject.
func buildModelsResponse(models []string) gin.H {
data := make([]gin.H, 0, len(models))
for _, model := range models {
modalities := []any{"text"}
if internalregistry.CodebuddyModelSupportsImages(model) || visionProxyEnabled {
if internalregistry.CodebuddyModelSupportsImages(model) {
modalities = []any{"text", "image"}
}
data = append(data, gin.H{
Expand Down Expand Up @@ -1799,41 +1792,6 @@
return resolveSupportedModelAlias(m, withoutPrefix)
}

// requestHasVisionInput reports whether a request body carries image input
// (OpenAI image_url parts or Responses input_image parts). It is used by the
// stream watchdog to extend the idle timeout for vision sub-agent loops.
func requestHasVisionInput(body []byte) bool {
if len(body) == 0 || !json.Valid(body) {
return false
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return false
}
return valueHasVisionInput(payload)
}

func valueHasVisionInput(value any) bool {
switch typed := value.(type) {
case map[string]any:
if typ, _ := typed["type"].(string); strings.EqualFold(strings.TrimSpace(typ), "input_image") || strings.EqualFold(strings.TrimSpace(typ), "image_url") {
return true
}
for _, child := range typed {
if valueHasVisionInput(child) {
return true
}
}
case []any:
for _, child := range typed {
if valueHasVisionInput(child) {
return true
}
}
}
return false
}

func stripModelPrefix(model string, spec *apiKeySpec) string {
trimmed := strings.TrimSpace(model)
if spec == nil || strings.TrimSpace(spec.ModelPrefix) == "" {
Expand Down
49 changes: 15 additions & 34 deletions sidecars/coderelay-proxy/manifest_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ func TestCodexClientModelsResponseShape(t *testing.T) {
}

func TestCodebuddyModelsResponseReportsInputModalities(t *testing.T) {
models := []string{"hy3", "hy3-preview", "deepseek-v4-pro", "deepseek-v4-flash", "hunyuan-2.0-instruct"}
models := []string{"hy3", "hy3-preview", "deepseek-v4-pro", "deepseek-v4-flash", "hunyuan-2.0-instruct", "glm-5v-turbo"}

modalitiesFor := func(response gin.H, modelID string) []any {
data, ok := response["data"].([]gin.H)
Expand All @@ -281,22 +281,20 @@ func TestCodebuddyModelsResponseReportsInputModalities(t *testing.T) {
}
}

// 情况1:vision-proxy 启用(默认 preprocess)——所有模型都报 image,
// 因为反代能透明处理纯文本模型的图片(描述后回填)。
enabled := buildModelsResponse(models, true)
for _, m := range models {
assertModalities(enabled, m, []any{"text", "image"})
}

// 情况2:vision-proxy 关闭——仅后端原生支持视觉的模型报 image。
// hy3/hy3-preview(app.asar supportsImages)报 image;
// deepseek(已移出白名单,后端返回拒绝)与 hunyuan(假视觉)报 text。
disabled := buildModelsResponse(models, false)
assertModalities(disabled, "hy3", []any{"text", "image"})
assertModalities(disabled, "hy3-preview", []any{"text", "image"})
assertModalities(disabled, "deepseek-v4-pro", []any{"text"})
assertModalities(disabled, "deepseek-v4-flash", []any{"text"})
assertModalities(disabled, "hunyuan-2.0-instruct", []any{"text"})
// 图片能力判定 = 在线清单 supportsImages + registry 里的实测校正表
// (排除 glm-5v-turbo,补入 glm-5.1 / deepseek-v3-2-volc)。
// 单测环境下 codebuddySynced 为空、回退静态 models.json:
// hy3/hy3-preview 在静态目录中含 supportsImages,报 image;
// deepseek(静态目录未标记图片能力)与 hunyuan(无图片能力)报 text;
// glm-5v-turbo 由校正表强制排除,即使目录误标也报 text。
// 注意:运行态同步到在线清单后,deepseek-v4.x 会被标记为图片能力 → 报 image。
response := buildModelsResponse(models)
assertModalities(response, "hy3", []any{"text", "image"})
assertModalities(response, "hy3-preview", []any{"text", "image"})
assertModalities(response, "deepseek-v4-pro", []any{"text"})
assertModalities(response, "deepseek-v4-flash", []any{"text"})
assertModalities(response, "hunyuan-2.0-instruct", []any{"text"})
assertModalities(response, "glm-5v-turbo", []any{"text"})
}

func TestCodexClientModelsResponsePreserves56Template(t *testing.T) {
Expand Down Expand Up @@ -446,23 +444,6 @@ func TestCodexClientModelsResponseDoesNotInjectFastMode(t *testing.T) {
}
}

func TestRequestVisionDetectionIgnoresToolSchemaFieldNames(t *testing.T) {
body := []byte(`{
"model":"deepseek-v4-pro",
"tools":[{
"type":"function",
"name":"inspect_url",
"parameters":{
"type":"object",
"properties":{"image_url":{"type":"string"}}
}
}]
}`)
if requestHasVisionInput(body) {
t.Fatal("tool schema field names must not be treated as image input")
}
}

func TestCodexClientModelsResponseEnablesWebsocketsWhenConfigured(t *testing.T) {
response := buildCodexClientModelsResponse([]string{"gpt-5.6-sol"}, &apiKeySpec{
ResponsesWebsockets: true,
Expand Down
2 changes: 1 addition & 1 deletion sidecars/coderelay-proxy/relay_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ func (s *relayServer) handleModels(c *gin.Context) {
c.JSON(http.StatusOK, buildCodexClientModelsResponse(models, spec, contextWindowsForAPIKey(s.manifest, spec)))
return
}
c.JSON(http.StatusOK, buildModelsResponse(models, s.manifest.visionProxyEnabled()))
c.JSON(http.StatusOK, buildModelsResponse(models))
}

func (s *relayServer) handleResponses(c *gin.Context) {
Expand Down
15 changes: 0 additions & 15 deletions sidecars/coderelay-proxy/stream_protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,6 @@ func (s *relayServer) streamTimeoutsForRequest(r *http.Request, body []byte, mod
profile.open = durationFromConfigMillis(s.cfg.Streaming.StreamOpenTimeoutMS, profile.open)
profile.idle = durationFromConfigMillis(s.cfg.Streaming.StreamIdleTimeoutMS, profile.idle)
}
// Requests carrying vision input are handled by the codebuddy vision
// sub-agent loop, which performs multiple rounds of upstream calls before
// emitting content. Give them a much longer idle timeout so the relay
// watchdog does not cancel mid-loop.
if requestHasVisionInput(body) {
if profile.idle < visionAgenticStreamIdleTimeout {
profile.idle = visionAgenticStreamIdleTimeout
}
}
if !isImageGenerationRequest(r, body, model) {
return profile
}
Expand Down Expand Up @@ -158,12 +149,6 @@ func relayContext(c *gin.Context) context.Context {
if c == nil || c.Request == nil {
return context.Background()
}
// Attach the vision sub-agent holder to the request context so the
// downstream executor's SetVisionSubagent and the request-completed
// diagnostic's GetVisionSubagent observe the same holder instance. Without
// this, both sides would hold distinct (or missing) holders and the
// request_completed event's visionSubagent flag would always be false.
c.Request = c.Request.WithContext(internallogging.WithVisionSubagentHolder(c.Request.Context()))
endpoint := c.Request.Method
if c.Request.URL != nil {
endpoint += " " + c.Request.URL.Path
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
// debug settings, proxy configuration, and API keys.
package config

import "strings"

// SDKConfig represents the application's configuration, loaded from a YAML file.
type SDKConfig struct {
EnableGeminiCLIEndpoint bool `yaml:"enable-gemini-cli-endpoint,omitempty" json:"enable-gemini-cli-endpoint,omitempty"`
Expand Down Expand Up @@ -64,12 +62,6 @@ type SDKConfig struct {
// NonStreamKeepAliveInterval controls how often blank lines are emitted for non-streaming responses.
// <= 0 disables keep-alives. Value is in seconds.
NonStreamKeepAliveInterval int `yaml:"nonstream-keepalive-interval,omitempty" json:"nonstream-keepalive-interval,omitempty"`

// CodebuddyVision configures the CodeBuddy vision-proxy layer. When a chat
// request carries image input for a model that does not natively support
// images, the proxy either swaps the model to a vision model (routing) or
// converts the images to text descriptions first (preprocess).
CodebuddyVision CodebuddyVisionConfig `yaml:"codebuddy-vision" json:"codebuddy-vision"`
}

// ClaudeCodeConfig configures Claude Code compatibility behavior.
Expand Down Expand Up @@ -97,72 +89,4 @@ type StreamingConfig struct {
BootstrapRetryMaxDelayMS int `yaml:"bootstrap-retry-max-delay-ms,omitempty" json:"bootstrap-retry-max-delay-ms,omitempty"`
}

// CodebuddyVisionConfig controls the CodeBuddy vision-proxy layer.
//
// The Tencent CodeBuddy backend accepts image input on a per-model basis. Some
// text-only models (e.g. hunyuan-2.0-instruct) silently ignore images and reply
// with "this model does not support image input" instead of an error. When
// enabled, the proxy detects image input and handles it for non-vision models.
type CodebuddyVisionConfig struct {
// Mode selects the strategy:
// - "off" (default): disabled; images pass through unchanged.
// - "routing": swap the request model to Model for non-vision models.
// - "preprocess": describe images with Model first, then continue with the
// original model.
// - "agentic": inject an inspect_image tool and run a server-side tool-calling
// loop so the text-only model can autonomously query the vision model
// multiple times during reasoning.
// Any other value falls back to "off".
Mode string `yaml:"mode" json:"mode"`

// Model is the vision model used as the routing target / preprocess engine.
// Default "hy4-preview".
Model string `yaml:"model" json:"model"`

// PreprocessPrompt overrides the user-visible prompt sent to the vision model
// in preprocess mode. Empty uses a built-in default.
PreprocessPrompt string `yaml:"preprocess-prompt" json:"preprocess-prompt"`

// MaxToolRounds caps the number of inspect_image tool-call iterations in
// agentic mode. Non-positive falls back to a default of 3.
MaxToolRounds int `yaml:"max-tool-rounds" json:"max-tool-rounds"`
}

// VisionMode constants for CodebuddyVisionConfig.Mode.
const (
CodebuddyVisionModeOff = "off"
CodebuddyVisionModeRouting = "routing"
CodebuddyVisionModePreprocess = "preprocess"
CodebuddyVisionModeAgentic = "agentic"
)

// NormalizedVisionMode returns the effective mode, mapping unknown values to "off".
func (c CodebuddyVisionConfig) NormalizedVisionMode() string {
switch strings.ToLower(strings.TrimSpace(c.Mode)) {
case CodebuddyVisionModeRouting:
return CodebuddyVisionModeRouting
case CodebuddyVisionModePreprocess:
return CodebuddyVisionModePreprocess
case CodebuddyVisionModeAgentic:
return CodebuddyVisionModeAgentic
default:
return CodebuddyVisionModeOff
}
}

// MaxVisionToolRounds returns the effective agentic iteration cap (default 3).
func (c CodebuddyVisionConfig) MaxVisionToolRounds() int {
if c.MaxToolRounds > 0 {
return c.MaxToolRounds
}
return 3
}

// VisionModel returns the configured vision model, defaulting to "hy4-preview".
func (c CodebuddyVisionConfig) VisionModel() string {
model := strings.TrimSpace(c.Model)
if model == "" {
return "hy4-preview"
}
return model
}
Loading
Loading