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
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ export interface ModelsResponse {
"retryable": boolean;
"protocol"?: string | null;
"protocols"?: { [_ in string]?: ProbeResponse } | null;

/**
* Model is the ID that was probed, and AutoSelectedModel says OneAgent chose
* it rather than the user. A failure on a model we picked is not evidence
* about the user's key, and the UI has to be able to say which it is.
*/
"model"?: string;
"auto_selected_model"?: boolean;
"models": string[] | null;
}

Expand Down Expand Up @@ -136,6 +144,14 @@ export interface ProbeResponse {
"retryable": boolean;
"protocol"?: string | null;
"protocols"?: { [_ in string]?: ProbeResponse } | null;

/**
* Model is the ID that was probed, and AutoSelectedModel says OneAgent chose
* it rather than the user. A failure on a model we picked is not evidence
* about the user's key, and the UI has to be able to say which it is.
*/
"model"?: string;
"auto_selected_model"?: boolean;
}

export interface ProviderIDRequest {
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/components/ConnectionStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { I18nProvider } from "../i18n";
import type { ProbeResponse } from "../types/api";
import { ConnectionStatus } from "./ConnectionStatus";

function probe(overrides: Partial<ProbeResponse> = {}): ProbeResponse {
return {
ok: false, reachable: true, status: 400, message: "模型请求被拒绝",
error_code: "PROTOCOL_UNSUPPORTED", retryable: false, ...overrides,
} as ProbeResponse;
}

// jsdom reports navigator.language as en-US, so I18nProvider resolves to English
// and the assertions below match the translated strings rather than the zh keys.
function show(result: ProbeResponse) {
render(<I18nProvider><ConnectionStatus state="success" result={result} /></I18nProvider>);
}

describe("ConnectionStatus", () => {
// A Provider's catalogue is mostly image, video and audio generators. One of
// those rejecting a chat payload used to be indistinguishable from a bad key,
// so the user read a wrong verdict about their credentials during setup.
it("names a model it chose itself when the probe fails", () => {
show(probe({ model: "wan-ai/wan2.1-t2v-14b", auto_selected_model: true }));
expect(screen.getByText(/wan-ai\/wan2\.1-t2v-14b/)).toBeTruthy();
expect(screen.getByText(/OneAgent chose the model/)).toBeTruthy();
});

it("stays quiet about the model the user chose themselves", () => {
// Their override, so the failure is the answer they asked for; explaining our
// choice would be both wrong and confusing.
show(probe({ model: "kwai/kling-v1-video", auto_selected_model: false }));
expect(screen.getByText("模型请求被拒绝")).toBeTruthy();
expect(screen.queryByText(/OneAgent chose the model/)).toBeNull();
});

it("does not blame the model when the key itself was rejected", () => {
// A rejected key is about the key whatever model carried the request.
show(probe({ error_code: "API_KEY_REJECTED", message: "API Key 无效", model: "wan-ai/wan2.1-t2v-14b", auto_selected_model: true }));
expect(screen.getByText("API Key 无效")).toBeTruthy();
expect(screen.queryByText(/OneAgent chose the model/)).toBeNull();
});

it("reports a successful probe with the provider's own message", () => {
show(probe({ ok: true, status: 200, message: "连接正常", error_code: null }));
expect(screen.getByText("连接正常")).toBeTruthy();
});
});
13 changes: 12 additions & 1 deletion frontend/src/components/ConnectionStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,21 @@ export function ConnectionStatus({ state, result }: { state: AsyncState; result:
);
}
const rejected = result?.error_code === "API_KEY_REJECTED";
// A model OneAgent chose is named in the failure. A Provider's catalogue is
// mostly image, video and audio generators, and one of those rejecting a chat
// payload otherwise reads as a broken key -- a wrong verdict about the user's
// credentials. Not shown when the key itself was rejected, which is about the
// key whatever model was used, nor when the user named the model themselves.
const blamedModel = !rejected && result?.auto_selected_model ? result.model : "";
return (
<div className={`inline-status ${rejected ? "status-warning" : "status-error"}`} role="alert">
{rejected ? <ShieldAlert size={17} /> : <AlertCircle size={17} />}
{result?.message || t("连接失败")}
<span>
{result?.message || t("连接失败")}
{blamedModel ? (
<small>{t("测试使用的模型 {model} 由 OneAgent 自动选择,可能不支持对话。可在上方自定义模型名称后重试", { model: blamedModel })}</small>
) : null}
</span>
</div>
);
}
4 changes: 4 additions & 0 deletions frontend/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ const english = {
"尚未测试连接": "Connection not tested",
"正在验证端点和 Key": "Validating endpoint and key",
"连接失败": "Connection failed",
// Shown when the probe failed on a model OneAgent picked rather than one the
// user typed. Without it, a video or image model rejecting a chat request reads
// as a rejected API key.
"测试使用的模型 {model} 由 OneAgent 自动选择,可能不支持对话。可在上方自定义模型名称后重试": "OneAgent chose the model {model} for this test, and it may not support chat. Enter a model name above and try again",
"查看安装日志": "View installation log",
"任务中心": "Task center",
"有任务正在运行": "A task is running",
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,28 @@
.inline-status.status-warning { color: var(--orange); background: transparent; }
.inline-status.status-error { color: var(--red); }

/* A failure can carry a second line explaining that the probed model was chosen
for the user. align-items moves to the top so the icon stays beside the first
line rather than centring against a two-line block. */
.inline-status:has(small) {
align-items: flex-start;
padding: 9px 0;
}

.inline-status > span {
min-width: 0;
display: grid;
gap: 3px;
}

/* Inherits neither the colour nor the weight of the error above it: the
explanation is context, not a second alarm. */
.inline-status small {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.45;
}

.model-picker {
width: 100%;
display: grid;
Expand Down
4 changes: 3 additions & 1 deletion internal/app/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ func (u *UseCases) activateAgentLocked(ctx context.Context, options ActivateAgen
return ActivateAgentResult{}, err
}

model, err := u.resolveProviderModel(ctx, target, apiKey, options.Model)
// The auto-selected flag only matters to the probe, which has to explain a
// failure; activation just needs a usable model.
model, _, err := u.resolveProviderModel(ctx, target, apiKey, options.Model)
if err != nil {
return ActivateAgentResult{}, err
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ func (u *UseCases) validateInstall(ctx context.Context, manifest catalog.Manifes
if u.provider == nil {
return options, oneerrors.New(oneerrors.InternalError, "Model discovery is not configured", oneerrors.WithStatus(501))
}
model, err := u.resolveProviderModel(ctx, target, options.APIKey, "")
model, _, err := u.resolveProviderModel(ctx, target, options.APIKey, "")
if err != nil {
return options, err
}
Expand Down
40 changes: 32 additions & 8 deletions internal/app/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package app
import (
"context"
"fmt"
"slices"
"sort"
"strings"
"sync"
Expand All @@ -25,6 +26,13 @@ type ProviderProbeOptions struct {
type ProviderProbeResult struct {
Primary provider.ProbeResult
Protocols map[string]provider.ProbeResult
// Model is the ID actually probed, and AutoSelectedModel says we chose it
// rather than the user. Together they let a failure distinguish "your key is
// wrong" from "we picked a model this endpoint does not serve for chat" --
// indistinguishable before, which is what made a bad auto-pick read as a
// credential problem.
Model string
AutoSelectedModel bool
}

// SaveProviderResult reports which Agents were rewritten after the edit so the
Expand All @@ -50,7 +58,7 @@ func (u *UseCases) ProbeProvider(ctx context.Context, options ProviderProbeOptio
if apiKey == "" {
apiKey = target.APIKey
}
model, err := u.resolveProviderModel(ctx, target, apiKey, options.Model)
model, autoSelected, err := u.resolveProviderModel(ctx, target, apiKey, options.Model)
if err != nil {
return ProviderProbeResult{}, err
}
Expand All @@ -73,7 +81,7 @@ func (u *UseCases) ProbeProvider(ctx context.Context, options ProviderProbeOptio
}
}
primary.OK = allOK
return ProviderProbeResult{Primary: primary, Protocols: results}, nil
return ProviderProbeResult{Primary: primary, Protocols: results, Model: model, AutoSelectedModel: autoSelected}, nil
}

func (u *UseCases) probeProtocols(ctx context.Context, protocols []string, apiKey, model string, baseFor func(string) string) (map[string]provider.ProbeResult, error) {
Expand Down Expand Up @@ -265,21 +273,37 @@ func (u *UseCases) DeleteProvider(ctx context.Context, providerID string) error
return u.providers.Delete(ctx, providerID)
}

func (u *UseCases) resolveProviderModel(ctx context.Context, target provider.Entry, apiKey, model string) (string, error) {
// resolveProviderModel decides which model the connection probe sends a chat
// payload to. The second return value reports whether the choice was ours rather
// than the user's, so a failure can say which of the two it is describing.
//
// Preference order, and why: a model the user typed wins outright, because the
// probe model is explicitly their override and a failure on it is the answer they
// asked for. Otherwise the Provider's manifest model wins over anything picked out
// of the live catalogue — it is a reviewed, known-chat ID for that Provider, while
// the catalogue of an aggregator is mostly video, image and audio generators whose
// names no denylist will ever fully enumerate. PickChatModel is the last resort,
// for a custom endpoint or a Provider whose manifest model it no longer serves.
func (u *UseCases) resolveProviderModel(ctx context.Context, target provider.Entry, apiKey, model string) (string, bool, error) {
if model = strings.TrimSpace(model); model != "" {
return model, nil
return model, false, nil
}
if apiKey == "" {
return target.FallbackModel, nil
return target.FallbackModel, true, nil
}
listing, err := u.provider.ListModels(ctx, "custom", apiKey, target.BaseURL)
if err != nil {
return "", err
return "", true, err
}
if listing.OK && len(listing.Models) > 0 {
return provider.PickChatModel(listing.Models), nil
// Only when the Provider actually serves it: probing a manifest model the
// endpoint has dropped would fail for a reason the user cannot act on.
if fallback := strings.TrimSpace(target.FallbackModel); fallback != "" && slices.Contains(listing.Models, fallback) {
return fallback, true, nil
}
return provider.PickChatModel(listing.Models), true, nil
}
return target.FallbackModel, nil
return target.FallbackModel, true, nil
}

func protocolsForAgents(agentIDs []string) ([]string, error) {
Expand Down
54 changes: 54 additions & 0 deletions internal/app/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,57 @@ func TestDeleteIgnoresStaleBindingWithoutAgentConfig(t *testing.T) {
t.Fatalf("stale binding blocked Provider deletion: %v", err)
}
}

// A built-in Provider's catalogue is mostly not chat models. With an empty probe
// model the live list used to win outright, so a video generator returned first
// became the model the connection test sent a chat payload to -- and the failure
// read as "your key is broken" during first-run setup.
func TestProbeProviderPrefersTheManifestModelOverAVideoModel(t *testing.T) {
var probed string
core := providerUseCases(t, appProviderDoer(func(request *http.Request) (*http.Response, error) {
if request.URL.Path == "/openai/v1/models" {
// Ordered as an aggregator really does: generators first.
return appProviderResponse(http.StatusOK, `{"data":[
{"id":"wan-ai/wan2.1-t2v-14b"},
{"id":"kwai/kling-v1-video"},
{"id":"deepseek/deepseek-v4-flash"},
{"id":"deepseek/deepseek-v4-pro"}
]}`), nil
}
body, _ := io.ReadAll(request.Body)
probed = string(body)
return appProviderResponse(http.StatusNoContent, ""), nil
}))
result, err := core.ProbeProvider(context.Background(), ProviderProbeOptions{
Provider: "ppio", APIKey: "key", AgentIDs: []string{"opencode"},
})
if err != nil || !result.Primary.OK {
t.Fatalf("probe = %#v, err=%v", result, err)
}
// The manifest's reviewed chat model, not the first survivor of the denylist.
if !strings.Contains(probed, "deepseek/deepseek-v4-flash") {
t.Fatalf("probed payload did not use the manifest model: %s", probed)
}
}

// A model the user typed is their override and must be probed verbatim, even when
// it is one the denylist would reject: a failure they asked for is information.
func TestProbeProviderKeepsAModelTheUserTyped(t *testing.T) {
var probed string
core := providerUseCases(t, appProviderDoer(func(request *http.Request) (*http.Response, error) {
if request.URL.Path == "/openai/v1/models" {
t.Fatal("discovery ran even though the user named a model")
}
body, _ := io.ReadAll(request.Body)
probed = string(body)
return appProviderResponse(http.StatusNoContent, ""), nil
}))
if _, err := core.ProbeProvider(context.Background(), ProviderProbeOptions{
Provider: "ppio", APIKey: "key", Model: "kwai/kling-v1-video", AgentIDs: []string{"opencode"},
}); err != nil {
t.Fatal(err)
}
if !strings.Contains(probed, "kwai/kling-v1-video") {
t.Fatalf("user's model was replaced: %s", probed)
}
}
9 changes: 9 additions & 0 deletions internal/binding/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ func (s *ProviderService) Probe(ctx context.Context, request ProbeRequest) (Prob
return ProbeResponse{}, err
}
response := probeResponse(result.Primary)
// Set on the top-level response only: the per-protocol entries all probed the
// same model, so repeating it there would suggest they could differ.
response.Model = result.Model
response.AutoSelectedModel = result.AutoSelectedModel
response.Protocols = make(map[string]ProbeResponse, len(result.Protocols))
for protocolID, protocolResult := range result.Protocols {
response.Protocols[protocolID] = probeResponse(protocolResult)
Expand Down Expand Up @@ -511,6 +515,11 @@ type ProbeResponse struct {
Retryable bool `json:"retryable"`
Protocol *string `json:"protocol,omitempty"`
Protocols map[string]ProbeResponse `json:"protocols,omitempty"`
// Model is the ID that was probed, and AutoSelectedModel says OneAgent chose
// it rather than the user. A failure on a model we picked is not evidence
// about the user's key, and the UI has to be able to say which it is.
Model string `json:"model,omitempty"`
AutoSelectedModel bool `json:"auto_selected_model,omitempty"`
}

type ModelsResponse struct {
Expand Down
8 changes: 7 additions & 1 deletion internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ const (
ProtocolResponses = catalog.ProtocolResponses
)

var nonChatModel = regexp.MustCompile(`(?i)(^|[-_/.])(embed(ding)?s?|rerank(er)?s?|ocr|whisper|asr|tts|speech|vl|vision|image|sdx?|flux|guard(rail)?s?|moderation|sql)([-_/.]|$)`)
// nonChatModel matches IDs that clearly name a non-chat endpoint. It is defence
// in depth, not the primary guard: a denylist over third-party model IDs always
// lags the vendors, so callers that have a reviewed model for the Provider should
// prefer that (see app.resolveProviderModel). The video, audio and music terms
// were added after a t2v model returned first by an aggregator got probed with a
// chat payload and the failure read as a bad API key.
var nonChatModel = regexp.MustCompile(`(?i)(^|[-_/.])(embed(ding)?s?|rerank(er)?s?|ocr|whisper|asr|tts|speech|voice|audio|music|bark|vl|vision|image|img|sdx?|flux|video|t2v|i2v|v2v|t2i|i2t|t2a|sora|veo|kling|hailuo|seedance|cogvideox?|wan[0-9.]*|guard(rail)?s?|moderation|sql)([-_/.]|$)`)

// ValidateBaseURL accepts an explicit HTTP(S) origin or path and rejects the
// forms that could smuggle credentials or control characters into requests.
Expand Down
32 changes: 32 additions & 0 deletions internal/provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,35 @@ func TestInvalidProtocolErrorIsStable(t *testing.T) {
t.Fatalf("invalid protocol error = %v", err)
}
}

// The generator IDs the aggregators OneAgent ships actually return. Before the
// video and audio terms were added, every one of these was treated as a chat
// model, so an aggregator listing one first had it probed with a chat payload.
func TestPickChatModelSkipsGeneratorModels(t *testing.T) {
generators := []string{
"wan-ai/wan2.1-t2v-14b", "kwai/kling-v1-video", "sora-2", "veo-3.0-generate-001",
"zai-org/cogvideox-5b", "tencent/hunyuan-video", "bytedance/seedance-1-0-pro",
"minimaxai/minimax-hailuo-02", "stabilityai/stable-video-diffusion", "suno/bark",
"black-forest-labs/flux-1-schnell", "qwen/qwen-image-edit", "minimaxai/minimax-speech-02",
}
for _, id := range generators {
if got := PickChatModel([]string{id, "deepseek/deepseek-v4-pro"}); got != "deepseek/deepseek-v4-pro" {
t.Errorf("PickChatModel picked the generator %q", got)
}
}
}

// The other half of the denylist's contract, and the reason it cannot simply be
// made broader: a term like "wan" or "veo" that matched real chat model families
// would push the probe onto models[0] and reintroduce the bug from the other side.
func TestPickChatModelKeepsRealChatModels(t *testing.T) {
for _, id := range []string{
"deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "gpt-5.6-terra", "claude-fable-5",
"qwen/qwen3-235b-a22b", "moonshotai/kimi-k2", "meta-llama/llama-4-maverick",
"zai-org/glm-4.6", "minimaxai/minimax-m2", "openai/gpt-oss-120b",
} {
if got := PickChatModel([]string{id}); got != id {
t.Errorf("chat model %q was classified as non-chat", id)
}
}
}
Loading