From 72e396f29fee71e9a3289a4df116640277915f4d Mon Sep 17 00:00:00 2001 From: leoxyang Date: Thu, 27 Aug 2026 18:48:39 +0800 Subject: [PATCH] feat(providers): integrate OpenCode Go wire routing --- CHANGELOG.md | 2 + .../components/cards/LocalServiceCard.tsx | 39 +++++++-- .../components/modals/CustomProviderModal.tsx | 25 ++++-- .../components/modals/ModelListEditor.tsx | 68 +++------------ .../components/modals/PresetProviderModal.tsx | 32 ++++++- .../components/modals/ProviderConfigModal.tsx | 27 ++++-- .../pages/Settings/Models/modelEntry.test.ts | 55 ++++++++++++ .../src/pages/Settings/Models/modelEntry.ts | 84 +++++++++++++++++++ .../src/pages/Settings/Models/modelMeta.tsx | 4 +- .../src/pages/Settings/Models/presetUtils.ts | 1 + .../pages/Settings/Models/providerApi.test.ts | 66 +++++++++++++++ .../src/pages/Settings/Models/providerApi.ts | 18 ++++ .../src/pages/Settings/Models/useProviders.ts | 22 +++++ .../pages/Settings/Models/wizardModelMeta.ts | 14 +++- dashboard/src/pages/Setup/steps/ModelStep.tsx | 75 ++++++++++------- .../src/pages/Setup/wizardClient.test.ts | 65 ++++++++++++++ dashboard/src/pages/Setup/wizardClient.ts | 11 +++ pyproject.toml | 2 +- src/octop/api/routers/providers.py | 48 ++++++++++- src/octop/api/routers/setup.py | 22 ++--- src/octop/cli/commands/models.py | 23 +++-- src/octop/infra/agents/providers/presets.py | 7 ++ src/octop/infra/agents/providers/probe.py | 14 +++- src/octop/infra/agents/providers/store.py | 67 +++++++++++++-- tests/integration/test_provider_test_draft.py | 51 +++++++++++ tests/integration/test_setup_wizard.py | 69 +++++++++++++++ tests/unit/agents/test_provider_store.py | 60 +++++++++++++ tests/unit/test_provider_preset_expansion.py | 74 +++++++++++++--- tests/unit/test_provider_probe.py | 27 ++++++ uv.lock | 2 +- 30 files changed, 912 insertions(+), 162 deletions(-) create mode 100644 dashboard/src/pages/Settings/Models/modelEntry.test.ts create mode 100644 dashboard/src/pages/Settings/Models/modelEntry.ts create mode 100644 dashboard/src/pages/Settings/Models/providerApi.test.ts create mode 100644 dashboard/src/pages/Setup/wizardClient.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d02a7d66..7f4b8ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - 合并冲突的双份 schema v10 迁移:投影表不再被 `010_kb_max_documents` 抢先抬版本而跳过;启动时幂等补建 `thread_messages` / `thread_history_projection` - Windows CI:`test_create_keeps_user_workspace_dir` 等真实 Harness 单测关闭 memory,避免 GC 线程与 `close()` 争用 SQLite 触发 access violation +- OpenCode Go 合并为稳定的 `opencode-go` Provider,并按模型显式路由 OpenAI Responses、Chat Completions 与 Anthropic Messages;补齐推理、上下文、输出上限和多模态元数据,草稿测试与初始化/设置保存不再丢失模型级协议字段 +- 兼容已持久化的旧 OpenCode Go 模型:`grok-4.5`/`grok-4.6` 自动选择 Responses,MiniMax/Qwen 自动选择 Anthropic Messages,无需自动改写现有 Provider、凭证或任务配置 ## [0.9.29] - 2026-08-27 diff --git a/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx b/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx index 378b150e..6cb96d00 100644 --- a/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx +++ b/dashboard/src/pages/Settings/Models/components/cards/LocalServiceCard.tsx @@ -35,15 +35,36 @@ interface LocalServiceCardProps { } function presetModelsToRows(preset: ProviderPreset): ProviderModel[] { - return preset.models.map((m) => ({ - id: m.id, - name: m.name, - enabled: false, - embedding: preset.id === "onnx" ? true : undefined, - task: preset.id === "onnx" ? "embedding" : undefined, - input: m.input?.length ? m.input : ["text"], - thinking: null, - })); + return preset.models.map((m) => { + const row: ProviderModel = { + id: m.id, + name: m.name, + enabled: false, + embedding: preset.id === "onnx" ? true : undefined, + task: preset.id === "onnx" ? "embedding" : undefined, + input: m.input?.length ? m.input : ["text"], + thinking: null, + }; + if (m.context_window != null) row.context_window = m.context_window; + if (m.max_input_tokens != null) { + row.max_input_tokens = m.max_input_tokens; + } + const maxOutput = m.max_output_tokens ?? m.max_tokens; + if (maxOutput != null) row.max_tokens = maxOutput; + if (m.reasoning != null) row.reasoning = m.reasoning; + if (m.reasoning_config !== undefined) { + row.reasoning_config = m.reasoning_config; + } + if (m.wire_api != null) row.wire_api = m.wire_api; + if (m.endpoint_base_url != null) { + row.endpoint_base_url = m.endpoint_base_url; + } + if (m.native_tool_search != null) { + row.native_tool_search = m.native_tool_search; + } + if (m.options !== undefined) row.options = m.options; + return row; + }); } export function LocalServiceCard({ diff --git a/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx index 439db47b..b6c5b10c 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/CustomProviderModal.tsx @@ -10,7 +10,11 @@ import { useTranslation } from "react-i18next"; import { request } from "../../../../../api/request"; import type { ProviderModel, ProviderRow } from "../../useProviders"; import { isEmbeddingModel } from "../../useProviders"; -import { fetchProviderModels, testProviderDraft } from "../../providerApi"; +import { + fetchProviderModels, + testProviderDraft, + toProviderProbeModel, +} from "../../providerApi"; import { ModelListEditor } from "./ModelListEditor"; import styles from "../../index.module.less"; @@ -83,13 +87,18 @@ export function CustomProviderModal({ if (!key) { return { ok: false, error: t("models.pleaseEnterApiKey") }; } + const model = models.find((item) => item.id === modelId); + if (!model) { + return { ok: false, error: t("models.testDraftNeedModel") }; + } return testProviderDraft({ name: (values.name || "draft").trim(), kind: values.kind, api_key: key, base_url: values.base_url?.trim() || null, model_id: modelId, - embedding: isEmbeddingModel(models.find((m) => m.id === modelId)), + model: toProviderProbeModel(model), + embedding: isEmbeddingModel(model), }); }; @@ -156,16 +165,14 @@ export function CustomProviderModal({ setSaving(true); const modelEntries = models.map((m) => { + const { max_output_tokens, ...persisted } = m; const entry: Record = { - id: m.id, - name: m.name, - enabled: m.enabled, + ...persisted, input: m.input?.length ? m.input : ["text"], - thinking: null, + thinking: m.thinking ?? null, }; - if (m.max_tokens != null) entry.max_tokens = m.max_tokens; - if (m.context_window != null) entry.context_window = m.context_window; - if (m.reasoning) entry.reasoning = true; + const maxOutput = m.max_tokens ?? max_output_tokens; + if (maxOutput != null) entry.max_tokens = maxOutput; if (isEmbeddingModel(m)) { entry.embedding = true; entry.task = "embedding"; diff --git a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx index 0e2718af..f598b5b8 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx @@ -33,6 +33,7 @@ import type { ProviderRow, ProviderModel } from "../../useProviders"; import { isEmbeddingModel } from "../../useProviders"; import { isOnnxProviderRow } from "../../presetUtils"; import { ModelMetaTags } from "../../modelMeta"; +import { buildProviderModelEntry } from "../../modelEntry"; import styles from "../../index.module.less"; export interface LocalModelDownloadControl { @@ -230,63 +231,12 @@ export function ModelListEditor({ } }; - const buildModelEntry = (values: Record): ProviderModel => { - const id = (values.id as string).trim(); - const name = (values.name as string | undefined)?.trim() || id; - const isOnnx = isOnnxProviderRow(provider); - const embedding = isOnnx || values.embedding === true; - const entry: ProviderModel = { - id, - name, - enabled: true, - input: ["text"], - thinking: null, - }; - if (embedding) { - entry.embedding = true; - entry.task = "embedding"; - return entry; - } - if (values.input != null) { - entry.input = (values.input as string[] | undefined) || ["text"]; - } - if (values.context_window != null) - entry.context_window = values.context_window as number; - if (values.max_tokens != null) - entry.max_tokens = values.max_tokens as number; - if (values.reasoning != null) entry.reasoning = values.reasoning as boolean; - if (values.reasoning === true) { - const efforts = (values.reasoning_efforts as string[] | undefined) || []; - entry.reasoning_config = { - supported: true, - toggle: values.reasoning_toggle !== false, - default_mode: - (values.reasoning_default_mode as "auto" | "enabled" | "disabled") || - "auto", - efforts, - default_effort: - (values.reasoning_default_effort as string | undefined) || null, - effort_type: - (values.reasoning_effort_type as "enum" | "token_budget") || "enum", - adapter: - (values.reasoning_adapter as - | "status_only" - | "thinking" - | "thinking_nested_effort" - | "openai_reasoning_effort" - | "anthropic_adaptive" - | "anthropic_budget" - | "dashscope" - | "openrouter") || "thinking", - }; - } - return entry; - }; - const handleAddModel = async () => { try { const values = await form.validateFields(); - const entry = buildModelEntry(values as Record); + const entry = buildProviderModelEntry(values as Record, { + isOnnx, + }); if (models.some((m) => m.id === entry.id)) { message.error(t("models.initialModelDuplicate", { name: entry.id })); return; @@ -306,8 +256,11 @@ export function ModelListEditor({ if (!editingModelId) return; try { const values = await form.validateFields(); - const entry = buildModelEntry(values as Record); const existing = models.find((m) => m.id === editingModelId); + const entry = buildProviderModelEntry(values as Record, { + existing, + isOnnx, + }); if (existing) { entry.enabled = existing.enabled; } @@ -327,7 +280,7 @@ export function ModelListEditor({ name: model.name, context_window: (model as Record).context_window ?? undefined, - max_tokens: (model as Record).max_tokens ?? undefined, + max_tokens: model.max_tokens ?? model.max_output_tokens ?? undefined, reasoning: (model as Record).reasoning ?? undefined, reasoning_toggle: model.reasoning_config?.toggle ?? true, reasoning_efforts: model.reasoning_config?.efforts ?? [], @@ -342,6 +295,7 @@ export function ModelListEditor({ const hasAdvanced = (model as Record).context_window != null || (model as Record).max_tokens != null || + (model as Record).max_output_tokens != null || (model as Record).reasoning != null; setShowAdvanced(hasAdvanced); }; @@ -421,7 +375,7 @@ export function ModelListEditor({ includeText input={m.input} context_window={m.context_window} - max_tokens={m.max_tokens} + max_tokens={m.max_tokens ?? m.max_output_tokens} reasoning={m.reasoning} /> )} diff --git a/dashboard/src/pages/Settings/Models/components/modals/PresetProviderModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/PresetProviderModal.tsx index f006f91b..0ce54db5 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/PresetProviderModal.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/PresetProviderModal.tsx @@ -17,7 +17,11 @@ import type { } from "../../useProviders"; import { isEmbeddingModel } from "../../useProviders"; import { CodexOAuthConnect } from "../CodexOAuthConnect"; -import { fetchProviderModels, testProviderDraft } from "../../providerApi"; +import { + fetchProviderModels, + testProviderDraft, + toProviderProbeModel, +} from "../../providerApi"; import { ModelListEditor } from "./ModelListEditor"; import styles from "../../index.module.less"; @@ -81,8 +85,23 @@ export function PresetProviderModal({ thinking: null, }; if (meta.reasoning) entry.reasoning = true; - if (ctx) entry.context_window = ctx; - if (meta.max_tokens) entry.max_tokens = meta.max_tokens; + if (m.reasoning_config !== undefined) { + entry.reasoning_config = m.reasoning_config; + } + if (ctx != null) entry.context_window = ctx; + if (m.max_input_tokens != null) { + entry.max_input_tokens = m.max_input_tokens; + } + const maxOutput = m.max_output_tokens ?? m.max_tokens; + if (maxOutput != null) entry.max_tokens = maxOutput; + if (m.wire_api != null) entry.wire_api = m.wire_api; + if (m.endpoint_base_url != null) { + entry.endpoint_base_url = m.endpoint_base_url; + } + if (m.native_tool_search != null) { + entry.native_tool_search = m.native_tool_search; + } + if (m.options !== undefined) entry.options = m.options; return entry; }); setDraftModels(baseModels); @@ -157,13 +176,18 @@ export function PresetProviderModal({ if (!key) { return { ok: false, error: t("models.pleaseEnterApiKey") }; } + const model = draftModels.find((item) => item.id === modelId); + if (!model) { + return { ok: false, error: t("models.testDraftNeedModel") }; + } return testProviderDraft({ name: values.name.trim(), kind: preset.protocol, api_key: key, base_url: values.base_url?.trim() || preset.base_url, model_id: modelId, - embedding: isEmbeddingModel(draftModels.find((m) => m.id === modelId)), + model: toProviderProbeModel(model), + embedding: isEmbeddingModel(model), }); }; diff --git a/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx index d61d1974..3f5d0034 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx @@ -7,14 +7,27 @@ * with local model list, download, and delete UI */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { App, Button, Divider, Form, Input, Modal, Progress, Select } from "antd"; +import { + App, + Button, + Divider, + Form, + Input, + Modal, + Progress, + Select, +} from "antd"; import { Download, Key, Loader2, Trash2, X, Zap } from "lucide-react"; import { useTranslation } from "react-i18next"; import { request } from "../../../../../api/request"; import type { ProviderRow, ProviderModel } from "../../useProviders"; import { isEmbeddingModel } from "../../useProviders"; -import { fetchProviderModels, testProviderDraft } from "../../providerApi"; +import { + fetchProviderModels, + testProviderDraft, + toProviderProbeModel, +} from "../../providerApi"; import { getProviderDocs } from "../../../../../assets/providers"; import { ollamaModelApi } from "../../../../../api/modules/ollamaModel"; import { onnxModelApi } from "../../../../../api/modules/onnxModel"; @@ -767,9 +780,12 @@ export function ProviderConfigModal({ return; } - const embedding = isEmbeddingModel( - draftModels.find((m) => m.id === modelId), - ); + const selectedModel = draftModels.find((m) => m.id === modelId); + if (!selectedModel) { + message.warning(t("models.testDraftNeedModel")); + return; + } + const embedding = isEmbeddingModel(selectedModel); const result = useDraft || !hasApiKey ? await testProviderDraft({ @@ -778,6 +794,7 @@ export function ProviderConfigModal({ api_key: draftApiKey || provider.api_key || undefined, base_url: draftBaseUrl || provider.base_url, model_id: modelId, + model: toProviderProbeModel(selectedModel), embedding, }) : await request<{ diff --git a/dashboard/src/pages/Settings/Models/modelEntry.test.ts b/dashboard/src/pages/Settings/Models/modelEntry.test.ts new file mode 100644 index 00000000..c9d1c696 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/modelEntry.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { buildProviderModelEntry } from "./modelEntry"; +import type { ProviderModel } from "./useProviders"; + +describe("buildProviderModelEntry", () => { + it("preserves opaque provider metadata while updating visible fields", () => { + const existing: ProviderModel & { + provider_extension: Record; + } = { + id: "grok-4.6", + name: "Grok 4.6", + enabled: false, + input: ["text"], + thinking: true, + context_window: 1_000_000, + max_tokens: 65_536, + max_input_tokens: 1_000_000, + wire_api: "openai_responses", + endpoint_base_url: "https://opencode.ai/zen/go/v1", + native_tool_search: true, + options: { thinking: { type: "enabled", budget_tokens: 8192 } }, + provider_extension: { cache_control: "ephemeral" }, + }; + + const updated = buildProviderModelEntry( + { + id: "grok-4.6", + name: "Grok 4.6 (reviewed)", + input: ["text", "image"], + context_window: 2_000_000, + max_tokens: 131_072, + reasoning: false, + embedding: false, + }, + { existing, isOnnx: false }, + ); + + expect(updated).toEqual( + expect.objectContaining({ + name: "Grok 4.6 (reviewed)", + enabled: false, + input: ["text", "image"], + thinking: true, + context_window: 2_000_000, + max_tokens: 131_072, + max_input_tokens: 1_000_000, + wire_api: "openai_responses", + endpoint_base_url: "https://opencode.ai/zen/go/v1", + native_tool_search: true, + options: { thinking: { type: "enabled", budget_tokens: 8192 } }, + provider_extension: { cache_control: "ephemeral" }, + }), + ); + }); +}); diff --git a/dashboard/src/pages/Settings/Models/modelEntry.ts b/dashboard/src/pages/Settings/Models/modelEntry.ts new file mode 100644 index 00000000..ddb6d147 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/modelEntry.ts @@ -0,0 +1,84 @@ +import type { ProviderModel } from "./useProviders"; + +/** Build a model row from the editor while preserving non-editable metadata. */ +export function buildProviderModelEntry( + values: Record, + options: { existing?: ProviderModel; isOnnx: boolean }, +): ProviderModel { + const { existing, isOnnx } = options; + const id = (values.id as string).trim(); + const name = (values.name as string | undefined)?.trim() || id; + const embedding = isOnnx || values.embedding === true; + const entry: ProviderModel = existing + ? { ...existing, id, name } + : { + id, + name, + enabled: true, + input: ["text"], + thinking: null, + }; + + if (embedding) { + entry.embedding = true; + entry.task = "embedding"; + // Chat-only fields are editable metadata, so switching to embedding clears + // them while opaque provider-specific fields remain on the spread object. + delete entry.context_window; + delete entry.max_tokens; + delete entry.max_output_tokens; + delete entry.reasoning; + delete entry.reasoning_config; + return entry; + } + + delete entry.embedding; + delete entry.task; + entry.input = (values.input as string[] | undefined) || ["text"]; + + delete entry.context_window; + if (values.context_window != null) { + entry.context_window = values.context_window as number; + } + + // max_output_tokens is the preset/probe alias; editor saves the canonical + // provider-row field and removes a stale alias when the visible value changes. + delete entry.max_tokens; + delete entry.max_output_tokens; + if (values.max_tokens != null) { + entry.max_tokens = values.max_tokens as number; + } + + delete entry.reasoning; + delete entry.reasoning_config; + if (values.reasoning != null) { + entry.reasoning = values.reasoning as boolean; + } + if (values.reasoning === true) { + const efforts = (values.reasoning_efforts as string[] | undefined) || []; + entry.reasoning_config = { + supported: true, + toggle: values.reasoning_toggle !== false, + default_mode: + (values.reasoning_default_mode as "auto" | "enabled" | "disabled") || + "auto", + efforts, + default_effort: + (values.reasoning_default_effort as string | undefined) || null, + effort_type: + (values.reasoning_effort_type as "enum" | "token_budget") || "enum", + adapter: + (values.reasoning_adapter as + | "status_only" + | "thinking" + | "thinking_nested_effort" + | "openai_reasoning_effort" + | "anthropic_adaptive" + | "anthropic_budget" + | "dashscope" + | "openrouter") || "thinking", + }; + } + + return entry; +} diff --git a/dashboard/src/pages/Settings/Models/modelMeta.tsx b/dashboard/src/pages/Settings/Models/modelMeta.tsx index 4eb2714f..36c27da9 100644 --- a/dashboard/src/pages/Settings/Models/modelMeta.tsx +++ b/dashboard/src/pages/Settings/Models/modelMeta.tsx @@ -10,6 +10,7 @@ export interface ModelMetaSource { context_window?: number | null; max_input_tokens?: number | null; max_tokens?: number | null; + max_output_tokens?: number | null; reasoning?: boolean | null; } @@ -28,6 +29,7 @@ export function ModelMetaTags({ context_window, max_input_tokens, max_tokens, + max_output_tokens, reasoning, className, includeText = false, @@ -74,7 +76,7 @@ export function ModelMetaTags({ ); } - const out = formatTokenCount(max_tokens); + const out = formatTokenCount(max_output_tokens ?? max_tokens); if (out) { tags.push( diff --git a/dashboard/src/pages/Settings/Models/presetUtils.ts b/dashboard/src/pages/Settings/Models/presetUtils.ts index 94ddfbad..72dde036 100644 --- a/dashboard/src/pages/Settings/Models/presetUtils.ts +++ b/dashboard/src/pages/Settings/Models/presetUtils.ts @@ -48,6 +48,7 @@ const VARIANT_LABELS: Record = { international: "International", zen_compatible: "Zen · Compatible", zen_anthropic: "Zen · Anthropic", + go: "Go", go_compatible: "Go · Compatible", go_anthropic: "Go · Anthropic", }; diff --git a/dashboard/src/pages/Settings/Models/providerApi.test.ts b/dashboard/src/pages/Settings/Models/providerApi.test.ts new file mode 100644 index 00000000..bdac25c3 --- /dev/null +++ b/dashboard/src/pages/Settings/Models/providerApi.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { request } = vi.hoisted(() => ({ request: vi.fn() })); + +vi.mock("../../../api/request", () => ({ request })); + +import { testProviderDraft, toProviderProbeModel } from "./providerApi"; + +describe("provider draft probes", () => { + beforeEach(() => { + request.mockReset(); + request.mockResolvedValue({ ok: true }); + }); + + it("sends complete model routing metadata and maps max_tokens", async () => { + const model = toProviderProbeModel({ + id: "qwen3.7-max", + name: "Qwen3.7 Max", + enabled: true, + input: ["text", "image"], + max_input_tokens: 1_000_000, + context_window: 1_000_000, + max_tokens: 65_536, + reasoning: true, + reasoning_config: { + supported: true, + toggle: true, + default_mode: "auto", + efforts: ["low", "high"], + default_effort: "high", + effort_type: "enum", + adapter: "anthropic_adaptive", + }, + wire_api: "anthropic_messages", + endpoint_base_url: "https://opencode.ai/zen/go", + native_tool_search: true, + options: { thinking: { type: "adaptive" } }, + }); + + await testProviderDraft({ + name: "OpenCode Go", + kind: "openai", + api_key: "secret", + base_url: "https://opencode.ai/zen/go/v1", + model_id: model.id, + model, + }); + + expect(model).not.toHaveProperty("max_tokens"); + expect(model.max_output_tokens).toBe(65_536); + expect(model.native_tool_search).toBe(true); + expect(request).toHaveBeenCalledWith("/admin/providers/test-draft", { + method: "POST", + body: JSON.stringify({ + name: "OpenCode Go", + kind: "openai", + api_key: "secret", + base_url: "https://opencode.ai/zen/go/v1", + model_id: "qwen3.7-max", + model, + extra_json: null, + embedding: false, + }), + }); + }); +}); diff --git a/dashboard/src/pages/Settings/Models/providerApi.ts b/dashboard/src/pages/Settings/Models/providerApi.ts index 343f9902..6d1580cb 100644 --- a/dashboard/src/pages/Settings/Models/providerApi.ts +++ b/dashboard/src/pages/Settings/Models/providerApi.ts @@ -1,4 +1,20 @@ import { request } from "../../../api/request"; +import type { ProviderModel } from "./useProviders"; + +export type ProviderProbeModel = Omit & { + max_output_tokens?: number; +}; + +/** Convert the persisted model shape into the metadata shape used by probes. */ +export function toProviderProbeModel(model: ProviderModel): ProviderProbeModel { + const { max_tokens, ...probeModel } = model; + return { + ...probeModel, + ...(probeModel.max_output_tokens != null || max_tokens == null + ? {} + : { max_output_tokens: max_tokens }), + }; +} export interface TestProviderDraftParams { name: string; @@ -6,6 +22,7 @@ export interface TestProviderDraftParams { api_key?: string; base_url?: string | null; model_id: string; + model: ProviderProbeModel; extra_json?: string | null; embedding?: boolean; } @@ -27,6 +44,7 @@ export async function testProviderDraft( api_key: params.api_key?.trim() || null, base_url: params.base_url?.trim() || null, model_id: params.model_id, + model: params.model, extra_json: params.extra_json ?? null, embedding: params.embedding === true, }), diff --git a/dashboard/src/pages/Settings/Models/useProviders.ts b/dashboard/src/pages/Settings/Models/useProviders.ts index 5c188178..b0827645 100644 --- a/dashboard/src/pages/Settings/Models/useProviders.ts +++ b/dashboard/src/pages/Settings/Models/useProviders.ts @@ -12,6 +12,12 @@ import type { ResolvedModel } from "../../../api/types"; export type { ResolvedModel }; +export type ModelWireApi = + | "openai_chat_completions" + | "openai_responses" + | "anthropic_messages" + | "bedrock_converse"; + export interface ProviderModel { id: string; name: string; @@ -21,7 +27,17 @@ export interface ProviderModel { reasoning?: boolean; reasoning_config?: ResolvedModel["reasoning_config"]; context_window?: number; + max_input_tokens?: number; max_tokens?: number; + /** Preset/probe alias. Persisted provider rows use max_tokens. */ + max_output_tokens?: number; + /** Concrete wire protocol for this model when it differs from provider.kind. */ + wire_api?: ModelWireApi | null; + /** Concrete API root for this model when it differs from provider.base_url. */ + endpoint_base_url?: string | null; + native_tool_search?: boolean | null; + /** Legacy/provider-specific model options kept opaque by the editor. */ + options?: Record | null; /** Embedding-only: excluded from chat picker and auto-route. */ embedding?: boolean; task?: string; @@ -40,8 +56,14 @@ export interface ProviderPresetModel { max_input_tokens?: number | null; context_window?: number | null; max_tokens?: number | null; + max_output_tokens?: number | null; input?: string[]; reasoning?: boolean | null; + reasoning_config?: ResolvedModel["reasoning_config"]; + wire_api?: ModelWireApi | null; + endpoint_base_url?: string | null; + native_tool_search?: boolean | null; + options?: Record | null; description?: string | null; } diff --git a/dashboard/src/pages/Settings/Models/wizardModelMeta.ts b/dashboard/src/pages/Settings/Models/wizardModelMeta.ts index 7c42687b..463febe2 100644 --- a/dashboard/src/pages/Settings/Models/wizardModelMeta.ts +++ b/dashboard/src/pages/Settings/Models/wizardModelMeta.ts @@ -1,4 +1,6 @@ import type { TFunction } from "i18next"; +import type { ReasoningCapability } from "../../../api/types/provider"; +import type { ModelWireApi } from "./useProviders"; import { formatTokenCount } from "./modelMeta"; export interface WizardModelSource { @@ -7,8 +9,14 @@ export interface WizardModelSource { max_input_tokens?: number | null; context_window?: number | null; max_tokens?: number | null; + max_output_tokens?: number | null; input?: string[]; reasoning?: boolean | null; + reasoning_config?: ReasoningCapability | null; + wire_api?: ModelWireApi | null; + endpoint_base_url?: string | null; + native_tool_search?: boolean | null; + options?: Record | null; description?: string | null; } @@ -53,7 +61,9 @@ export function enrichWizardModel( ): WizardModelDisplayMeta { const context = model.context_window ?? model.max_input_tokens ?? null; const input = inferInputModalities(model.id, model.input); - const reasoning = inferReasoning(model.id, model.reasoning); + const reasoning = + model.reasoning_config?.supported === true || + inferReasoning(model.id, model.reasoning); let description = model.description?.trim() || undefined; if (!description) { @@ -74,7 +84,7 @@ export function enrichWizardModel( input, reasoning: reasoning || undefined, context_window: context, - max_tokens: model.max_tokens ?? null, + max_tokens: model.max_output_tokens ?? model.max_tokens ?? null, description, }; } diff --git a/dashboard/src/pages/Setup/steps/ModelStep.tsx b/dashboard/src/pages/Setup/steps/ModelStep.tsx index 0fab1698..63491366 100644 --- a/dashboard/src/pages/Setup/steps/ModelStep.tsx +++ b/dashboard/src/pages/Setup/steps/ModelStep.tsx @@ -42,29 +42,8 @@ import type { ProviderPreset as AdminProviderPreset } from "../../Settings/Model const { Text } = Typography; -interface ProviderPresetModel { - id: string; - name: string; - max_input_tokens?: number | null; - context_window?: number | null; - max_tokens?: number | null; - input?: string[]; - reasoning?: boolean | null; - description?: string | null; -} - -interface ProviderPreset { - id: string; - name: string; - base_url: string; - protocol: string; - api_key_prefix: string; - models: ProviderPresetModel[]; - provider_group?: string; - provider_group_name?: string; - provider_variant?: string; - logo_id?: string; -} +type ProviderPreset = AdminProviderPreset; +type ProviderPresetModel = ProviderPreset["models"][number]; type PresetDisplayItem = | { kind: "single"; preset: ProviderPreset } @@ -89,8 +68,14 @@ interface CustomModelEntry { name: string; input: string[]; context_window?: number; - max_tokens?: number; + max_input_tokens?: number; + max_output_tokens?: number; reasoning?: boolean; + reasoning_config?: ProviderPresetModel["reasoning_config"]; + wire_api?: ProviderPresetModel["wire_api"]; + endpoint_base_url?: string | null; + native_tool_search?: boolean | null; + options?: Record | null; } interface Props { @@ -288,7 +273,16 @@ export default function ModelStep({ onBack, onSkip, onContinue }: Props) { id: string; name: string; input?: string[]; - reasoning?: boolean; + reasoning?: boolean | null; + reasoning_config?: ProviderPresetModel["reasoning_config"]; + max_input_tokens?: number | null; + context_window?: number | null; + max_tokens?: number | null; + max_output_tokens?: number | null; + wire_api?: ProviderPresetModel["wire_api"]; + endpoint_base_url?: string | null; + native_tool_search?: boolean | null; + options?: Record | null; }>, ): WizardProviderModel[] => entries.map((m) => { @@ -300,6 +294,27 @@ export default function ModelStep({ onBack, onSkip, onContinue }: Props) { thinking: null, }; if (m.reasoning) model.reasoning = true; + if (m.reasoning_config !== undefined) { + model.reasoning_config = m.reasoning_config; + } + if (m.max_input_tokens != null) { + model.max_input_tokens = m.max_input_tokens; + } + if (m.context_window != null) { + model.context_window = m.context_window; + } + const maxOutput = m.max_output_tokens ?? m.max_tokens; + if (maxOutput != null) { + model.max_output_tokens = maxOutput; + } + if (m.wire_api !== undefined) model.wire_api = m.wire_api; + if (m.endpoint_base_url !== undefined) { + model.endpoint_base_url = m.endpoint_base_url; + } + if (m.native_tool_search !== undefined) { + model.native_tool_search = m.native_tool_search; + } + if (m.options !== undefined) model.options = m.options; return model; }); @@ -344,8 +359,7 @@ export default function ModelStep({ onBack, onSkip, onContinue }: Props) { .map((m) => { const meta = enrichWizardModel(m, t); return { - id: m.id, - name: m.name, + ...m, input: meta.input, ...(meta.reasoning ? { reasoning: true } : {}), }; @@ -515,6 +529,7 @@ export default function ModelStep({ onBack, onSkip, onContinue }: Props) { api_key: draft.api_key, base_url: draft.base_url, model_id: draft.models[0].id, + model: draft.models[0], }, probeToken, ); @@ -601,7 +616,7 @@ export default function ModelStep({ onBack, onSkip, onContinue }: Props) { entry.context_window = values.context_window as number; } if (values.max_tokens != null) { - entry.max_tokens = values.max_tokens as number; + entry.max_output_tokens = values.max_tokens as number; } if (values.reasoning) { entry.reasoning = true; @@ -629,7 +644,7 @@ export default function ModelStep({ onBack, onSkip, onContinue }: Props) { entry.context_window = values.context_window as number; } if (values.max_tokens != null) { - entry.max_tokens = values.max_tokens as number; + entry.max_output_tokens = values.max_tokens as number; } if (values.reasoning) { entry.reasoning = true; @@ -729,7 +744,9 @@ export default function ModelStep({ onBack, onSkip, onContinue }: Props) { context_window?: number | null; max_input_tokens?: number | null; max_tokens?: number | null; + max_output_tokens?: number | null; reasoning?: boolean | null; + reasoning_config?: ProviderPresetModel["reasoning_config"]; description?: string | null; }) => { const meta = enrichWizardModel(m, t); diff --git a/dashboard/src/pages/Setup/wizardClient.test.ts b/dashboard/src/pages/Setup/wizardClient.test.ts new file mode 100644 index 00000000..33b35332 --- /dev/null +++ b/dashboard/src/pages/Setup/wizardClient.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { request } = vi.hoisted(() => ({ request: vi.fn() })); + +vi.mock("../../api/request", () => ({ request })); + +import { wizardApi, type WizardProviderModel } from "./wizardClient"; + +describe("setup provider probes", () => { + beforeEach(() => { + request.mockReset(); + request.mockResolvedValue({ ok: true }); + }); + + it("sends the selected model metadata with the legacy model id", async () => { + const model: WizardProviderModel = { + id: "grok-4.6", + name: "Grok 4.6", + enabled: true, + input: ["text", "image"], + thinking: null, + reasoning: true, + max_input_tokens: 2_000_000, + context_window: 2_000_000, + max_output_tokens: 131_072, + wire_api: "openai_responses", + endpoint_base_url: "https://opencode.ai/zen/go/v1", + native_tool_search: true, + reasoning_config: { + supported: true, + toggle: true, + default_mode: "auto", + efforts: ["low", "high"], + default_effort: "high", + effort_type: "enum", + adapter: "openai_reasoning_effort", + }, + }; + + await wizardApi.testProvider( + { + name: "OpenCode Go", + type: "openai", + api_key: "secret", + base_url: "https://opencode.ai/zen/go/v1", + model_id: model.id, + model, + }, + "wizard-token", + ); + + expect(request).toHaveBeenCalledWith("/setup/test-provider", { + method: "POST", + body: JSON.stringify({ + name: "OpenCode Go", + type: "openai", + api_key: "secret", + base_url: "https://opencode.ai/zen/go/v1", + model_id: "grok-4.6", + model, + }), + headers: { Authorization: "Bearer wizard-token" }, + }); + }); +}); diff --git a/dashboard/src/pages/Setup/wizardClient.ts b/dashboard/src/pages/Setup/wizardClient.ts index baffc858..76cf32bc 100644 --- a/dashboard/src/pages/Setup/wizardClient.ts +++ b/dashboard/src/pages/Setup/wizardClient.ts @@ -7,6 +7,8 @@ */ import { request } from "../../api/request"; +import type { ReasoningCapability } from "../../api/types/provider"; +import type { ModelWireApi } from "../Settings/Models/useProviders"; export interface VerifyResponse { wizard_token: string; @@ -29,6 +31,14 @@ export interface WizardProviderModel { input: string[]; thinking: null; reasoning?: boolean; + reasoning_config?: ReasoningCapability | null; + max_input_tokens?: number; + context_window?: number; + max_output_tokens?: number; + wire_api?: ModelWireApi | null; + endpoint_base_url?: string | null; + native_tool_search?: boolean | null; + options?: Record | null; } export interface ProviderDraft { @@ -181,6 +191,7 @@ export const wizardApi = { api_key: string; base_url?: string; model_id: string; + model: WizardProviderModel; }, wizardToken: string, ) => diff --git a/pyproject.toml b/pyproject.toml index 7614153b..06d5bdd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "apscheduler>=3.10,<4", "argon2-cffi>=23.1", "pyjwt>=2.8", - "orcakit-harness-agent[all]>=0.9.27", + "orcakit-harness-agent[all]>=0.9.28", "harness-memory>=0.9.7", "harness-gateway>=0.9.3", "cryptography>=41", diff --git a/src/octop/api/routers/providers.py b/src/octop/api/routers/providers.py index e030f13f..a5e9a7b8 100644 --- a/src/octop/api/routers/providers.py +++ b/src/octop/api/routers/providers.py @@ -9,7 +9,8 @@ from typing import Any, cast from fastapi import APIRouter, Depends -from pydantic import BaseModel +from harness_agent.config import WireAPIName +from pydantic import BaseModel, TypeAdapter, field_validator from octop.api.deps import current_user, get_server, require_permission from octop.infra.agents.providers.model_flags import is_local_runtime_provider @@ -42,6 +43,25 @@ router = APIRouter() +_WIRE_API_ADAPTER: TypeAdapter[WireAPIName] = TypeAdapter(WireAPIName) + + +def _validate_model_wire_api(model: dict[str, Any]) -> dict[str, Any]: + """Reject unknown wire protocol names at the API boundary.""" + wire_api = model.get("wire_api") + if wire_api is not None: + _WIRE_API_ADAPTER.validate_python(wire_api) + return model + + +def _validate_models_wire_api( + models: list[dict[str, Any]] | None, +) -> list[dict[str, Any]] | None: + if models is not None: + for model in models: + _validate_model_wire_api(model) + return models + class ProviderCreateBody(BaseModel): name: str @@ -52,6 +72,14 @@ class ProviderCreateBody(BaseModel): models: list[dict[str, Any]] | None = None note: str | None = None + @field_validator("models") + @classmethod + def validate_models_wire_api( + cls, + models: list[dict[str, Any]] | None, + ) -> list[dict[str, Any]] | None: + return _validate_models_wire_api(models) + class ProviderPatchBody(BaseModel): kind: str | None = None @@ -62,6 +90,14 @@ class ProviderPatchBody(BaseModel): note: str | None = None enabled: bool | None = None + @field_validator("models") + @classmethod + def validate_models_wire_api( + cls, + models: list[dict[str, Any]] | None, + ) -> list[dict[str, Any]] | None: + return _validate_models_wire_api(models) + # Fields that affect harness factory / agent runtime when patched. _PROVIDER_REHYDRATE_FIELDS = frozenset( @@ -87,6 +123,15 @@ class ProviderTestDraftBody(BaseModel): model_id: str extra_json: str | None = None embedding: bool = False + model: dict[str, Any] | None = None + + @field_validator("model") + @classmethod + def validate_model_wire_api( + cls, + model: dict[str, Any] | None, + ) -> dict[str, Any] | None: + return _validate_model_wire_api(model) if model is not None else None class ProviderFetchModelsBody(BaseModel): @@ -314,6 +359,7 @@ async def admin_test_provider_draft( model_id=model_id, extra_json=body.extra_json, embedding=body.embedding, + model_metadata=body.model, ) return await probe_provider_row(row, model_id=model_id, embedding=body.embedding) diff --git a/src/octop/api/routers/setup.py b/src/octop/api/routers/setup.py index 7387453b..8b1d44c6 100644 --- a/src/octop/api/routers/setup.py +++ b/src/octop/api/routers/setup.py @@ -8,6 +8,7 @@ from typing import Any from fastapi import APIRouter, Depends, Header, Request +from harness_agent.config import WireAPIName from pydantic import BaseModel, Field from octop.api.deps import get_server, require_database, resolve_user_from_token, sign_token @@ -46,6 +47,14 @@ class ProviderModelDraft(BaseModel): input: list[str] = Field(default_factory=list) thinking: Any | None = None reasoning: bool | None = None + reasoning_config: dict[str, Any] | None = None + wire_api: WireAPIName | None = None + endpoint_base_url: str | None = None + max_input_tokens: int | None = None + context_window: int | None = None + max_output_tokens: int | None = None + max_tokens: int | None = None + native_tool_search: bool | None = None class ProviderDraftBody(BaseModel): @@ -67,6 +76,7 @@ class ProviderTestBody(BaseModel): api_key: str = "" base_url: str | None = None model_id: str = Field(min_length=1) + model: ProviderModelDraft | None = None class DatabaseSetupBody(BaseModel): @@ -180,16 +190,7 @@ async def _apply_provider_draft(server: Any, draft: ProviderDraftBody) -> None: raise OctopError(ErrorCode.INTERNAL_ERROR, "base_url is required", status=400) def _model_entry(m: ProviderModelDraft) -> dict[str, Any]: - entry: dict[str, Any] = { - "id": m.id, - "name": m.name, - "enabled": m.enabled, - "input": m.input, - "thinking": m.thinking, - } - if m.reasoning: - entry["reasoning"] = True - return entry + return m.model_dump(exclude_none=True) models = [_model_entry(m) for m in draft.models if m.enabled] if not models and draft.models: @@ -404,6 +405,7 @@ async def test_provider_draft( api_key=body.api_key or None, base_url=body.base_url, model_id=body.model_id, + model_metadata=body.model.model_dump(exclude_none=True) if body.model else None, ) return await probe_provider_row(row) diff --git a/src/octop/cli/commands/models.py b/src/octop/cli/commands/models.py index 3d73dabb..50b6099e 100644 --- a/src/octop/cli/commands/models.py +++ b/src/octop/cli/commands/models.py @@ -118,15 +118,20 @@ def config_models() -> None: base_url = _prompts.text("Base URL:", default=base_url) kind = str(preset.get("kind") or preset.get("protocol") or preset_id) raw_models = preset.get("models") or [] - models_payload = [ - { - "id": m.get("id") or m.get("model_id"), - "name": m.get("name") or m.get("id"), - "enabled": True, - } - for m in raw_models - if m.get("id") or m.get("model_id") - ] + models_payload = [] + for model in raw_models: + model_id = model.get("id") or model.get("model_id") + if not model_id: + continue + # Keep preset protocol/capability metadata. Mixed-protocol + # providers such as OpenCode Go cannot be reconstructed from the + # provider kind and model id alone. + entry = dict(model) + entry.pop("model_id", None) + entry["id"] = model_id + entry["name"] = model.get("name") or model_id + entry["enabled"] = True + models_payload.append(entry) body = { "name": display_name, "kind": kind, diff --git a/src/octop/infra/agents/providers/presets.py b/src/octop/infra/agents/providers/presets.py index c707662c..790214ec 100644 --- a/src/octop/infra/agents/providers/presets.py +++ b/src/octop/infra/agents/providers/presets.py @@ -260,6 +260,13 @@ def load_provider_presets() -> list[dict[str, Any]]: for preset in out: provider_id = str(preset.get("id") or "") for model in preset.get("models") or []: + # Harness templates are the source of truth for model-specific + # request semantics. The local table below is only a compatibility + # fallback for older/custom templates that expose ``reasoning`` but + # not the normalized capability payload. + if isinstance(model.get("reasoning_config"), dict): + model["reasoning"] = True + continue profile = _reasoning_profile(provider_id, str(model.get("id") or "")) if profile is not None: model["reasoning"] = True diff --git a/src/octop/infra/agents/providers/probe.py b/src/octop/infra/agents/providers/probe.py index cd4b554d..abbdcffc 100644 --- a/src/octop/infra/agents/providers/probe.py +++ b/src/octop/infra/agents/providers/probe.py @@ -12,6 +12,7 @@ import httpx from octop.infra.agents.providers import KIND_TO_PROTOCOL +from octop.infra.agents.providers.store import model_config_from_provider_entry logger = logging.getLogger(__name__) @@ -40,7 +41,7 @@ def _is_codex_base_url(base_url: str | None) -> bool: def build_probe_chat_model(row: Any, *, model_id: str | None = None) -> Any: """Construct a chat model from a provider row for probing.""" - from harness_agent.config import ModelConfig, ProviderConfig + from harness_agent.config import ProviderConfig from harness_agent.llm.factory import build_chat_model protocol = KIND_TO_PROTOCOL.get(row.kind, row.kind) @@ -49,8 +50,10 @@ def build_probe_chat_model(row: Any, *, model_id: str | None = None) -> Any: models = row.get_models() if hasattr(row, "get_models") else [] mid = model_id or (models[0]["id"] if models else "gpt-4o-mini") entry = next((m for m in models if m.get("id") == mid), None) - display_name = (entry or {}).get("name") or mid - model = ModelConfig(id=mid, name=display_name) + model_data = dict(entry or {}) + model_data["id"] = mid + model_data.setdefault("name", mid) + model = model_config_from_provider_entry(model_data, provider_base_url=base_url) if _is_codex_base_url(base_url): from langchain_openai import ChatOpenAI @@ -85,9 +88,12 @@ def make_probe_provider_row( model_id: str, extra_json: str | None = None, embedding: bool = False, + model_metadata: dict[str, Any] | None = None, ) -> Any: """Build a ProviderRow-like object for connectivity probes.""" - model: dict[str, Any] = {"id": model_id, "name": model_id} + model: dict[str, Any] = dict(model_metadata or {}) + model["id"] = model_id + model.setdefault("name", model_id) if embedding: model["embedding"] = True model["task"] = "embedding" diff --git a/src/octop/infra/agents/providers/store.py b/src/octop/infra/agents/providers/store.py index b3e60b28..4ccc5ef5 100644 --- a/src/octop/infra/agents/providers/store.py +++ b/src/octop/infra/agents/providers/store.py @@ -23,6 +23,24 @@ "gemini": "openai", } +_OPENCODE_GO_RESPONSES_MODELS = frozenset( + { + # Grok 4.5 is kept for already-persisted OpenCode Go rows. The + # current bundled catalog exposes Grok 4.6 instead. + "grok-4.5", + "grok-4.6", + "gpt-5.6-luna", + "muse-spark-1.2-contributor", + } +) + +_OPENCODE_GO_ANTHROPIC_PREFIXES = ( + "minimax-", + "qwen3.6-", + "qwen3.7-", + "qwen3.8-", +) + def _infer_model_input_modalities( model_id: str, @@ -54,6 +72,42 @@ def _model_dict_supports_image(model: dict[str, Any]) -> bool: return "image" in _infer_model_input_modalities(model_id, explicit) +def model_config_from_provider_entry( + raw: dict[str, object], + *, + provider_base_url: str | None = None, +) -> ModelConfig: + """Build a harness model config without dropping model-level wire metadata. + + ``max_tokens`` is OCTOP's persisted/UI alias for the provider's maximum + output tokens. Older OpenCode Go rows predate ``wire_api``; infer only the + documented exceptional routes so those rows keep working without a DB + migration or an automatic production-provider rewrite. + """ + data = dict(raw) + model_id = str(data.get("id") or "") + explicit = data.get("input") + inputs = list(explicit) if isinstance(explicit, list) else ["text"] + data["input"] = _infer_model_input_modalities(model_id, inputs) + + if not data.get("max_output_tokens") and data.get("max_tokens"): + data["max_output_tokens"] = data["max_tokens"] + + base_url = (provider_base_url or "").rstrip("/") + if not data.get("wire_api") and "opencode.ai/zen/go" in base_url.lower(): + lower_model = model_id.lower() + if lower_model in _OPENCODE_GO_RESPONSES_MODELS: + data["wire_api"] = "openai_responses" + elif lower_model.startswith(_OPENCODE_GO_ANTHROPIC_PREFIXES): + data["wire_api"] = "anthropic_messages" + if not data.get("endpoint_base_url"): + data["endpoint_base_url"] = base_url.removesuffix("/v1") + + # ModelConfig keeps total context, prompt cap, and output cap distinct, + # while still accepting legacy rows that only contain one context key. + return ModelConfig.from_dict(data) + + def enabled_model_refs( provider_name: str, models: list[dict[str, Any]], @@ -136,7 +190,7 @@ def build_harness_configs(self) -> list[ProviderConfig]: protocol = KIND_TO_PROTOCOL.get(row.kind, "openai") raw_models = json.loads(row.models_json) if getattr(row, "models_json", None) else [] models = [ - self._model_config_from_row(m) + model_config_from_provider_entry(m, provider_base_url=row.base_url) for m in raw_models if is_chat_eligible_model(m, provider_name=row.name, provider_api_key=row.api_key) ] @@ -165,14 +219,8 @@ def build_harness_configs(self) -> list[ProviderConfig]: @staticmethod def _model_config_from_row(raw: dict[str, object]) -> ModelConfig: - data = dict(raw) - model_id = str(data.get("id") or "") - explicit = data.get("input") - inputs = list(explicit) if isinstance(explicit, list) else ["text"] - data["input"] = _infer_model_input_modalities(model_id, inputs) - # ModelConfig keeps total context, prompt cap, and output cap distinct, - # while still accepting legacy rows that only contain one context key. - return ModelConfig.from_dict(data) + """Compatibility wrapper for callers/tests using the old helper.""" + return model_config_from_provider_entry(raw) def is_model_ref_multimodal(self, ref: str) -> bool: """True when *ref* resolves to a model that accepts images.""" @@ -303,4 +351,5 @@ def find_agents_using_provider( "ProviderStore", "clear_stale_pins_for_provider", "enabled_model_refs", + "model_config_from_provider_entry", ] diff --git a/tests/integration/test_provider_test_draft.py b/tests/integration/test_provider_test_draft.py index a324c289..0b82bc98 100644 --- a/tests/integration/test_provider_test_draft.py +++ b/tests/integration/test_provider_test_draft.py @@ -40,6 +40,57 @@ async def test_admin_test_draft_requires_model_id(env: Any) -> None: assert r.json()["ok"] is False +async def test_admin_test_draft_forwards_selected_model_wire_metadata(env: Any) -> None: + client, _srv, auth = env + probe = AsyncMock(return_value={"ok": True, "latency_ms": 1}) + with patch("octop.api.routers.providers.probe_provider_row", probe): + r = await client.post( + "/api/admin/providers/test-draft", + headers=auth, + json={ + "name": "opencode-go", + "kind": "openai", + "api_key": "sk-test", + "base_url": "https://opencode.ai/zen/go/v1", + "model_id": "qwen3.8-max", + "model": { + "id": "qwen3.8-max", + "name": "Qwen 3.8 Max", + "wire_api": "anthropic_messages", + "endpoint_base_url": "https://opencode.ai/zen/go", + }, + }, + ) + + assert r.status_code == 200, r.text + assert r.json()["ok"] is True + row = probe.await_args.args[0] + assert row.get_models()[0]["wire_api"] == "anthropic_messages" + assert row.get_models()[0]["endpoint_base_url"] == "https://opencode.ai/zen/go" + + +async def test_admin_test_draft_rejects_unknown_wire_api(env: Any) -> None: + client, _srv, auth = env + r = await client.post( + "/api/admin/providers/test-draft", + headers=auth, + json={ + "name": "opencode-go", + "kind": "openai", + "api_key": "sk-test", + "base_url": "https://opencode.ai/zen/go/v1", + "model_id": "qwen3.8-max", + "model": { + "id": "qwen3.8-max", + "name": "Qwen 3.8 Max", + "wire_api": "anthropic-messages", + }, + }, + ) + + assert r.status_code == 422 + + async def test_admin_codex_oauth_start(env: Any) -> None: client, _srv, auth = env fake_info = { diff --git a/tests/integration/test_setup_wizard.py b/tests/integration/test_setup_wizard.py index fbe13857..ae3effb0 100644 --- a/tests/integration/test_setup_wizard.py +++ b/tests/integration/test_setup_wizard.py @@ -9,6 +9,7 @@ import asyncio from pathlib import Path from typing import Any +from unittest.mock import AsyncMock, patch import pytest @@ -255,6 +256,60 @@ async def test_test_provider_returns_error_for_bad_key(env: Any) -> None: assert r.json()["ok"] is False +async def test_test_provider_forwards_selected_model_wire_metadata(env: Any) -> None: + c, _srv, _home = env + pw = read_password(Path.home()) + tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] + probe = AsyncMock(return_value={"ok": True, "latency_ms": 1}) + with patch("octop.api.routers.setup.probe_provider_row", probe): + r = await c.post( + "/api/setup/test-provider", + json={ + "name": "opencode-go", + "type": "openai", + "api_key": "sk-test", + "base_url": "https://opencode.ai/zen/go/v1", + "model_id": "qwen3.8-max", + "model": { + "id": "qwen3.8-max", + "name": "Qwen 3.8 Max", + "wire_api": "anthropic_messages", + "endpoint_base_url": "https://opencode.ai/zen/go", + }, + }, + headers={"Authorization": f"Bearer {tok}"}, + ) + + assert r.status_code == 200, r.text + row = probe.await_args.args[0] + assert row.get_models()[0]["wire_api"] == "anthropic_messages" + assert row.get_models()[0]["endpoint_base_url"] == "https://opencode.ai/zen/go" + + +async def test_test_provider_rejects_unknown_wire_api(env: Any) -> None: + c, _srv, _home = env + pw = read_password(Path.home()) + tok = (await c.post("/api/setup/verify-password", json={"password": pw})).json()["wizard_token"] + r = await c.post( + "/api/setup/test-provider", + json={ + "name": "opencode-go", + "type": "openai", + "api_key": "sk-test", + "base_url": "https://opencode.ai/zen/go/v1", + "model_id": "qwen3.8-max", + "model": { + "id": "qwen3.8-max", + "name": "Qwen 3.8 Max", + "wire_api": "anthropic-messages", + }, + }, + headers={"Authorization": f"Bearer {tok}"}, + ) + + assert r.status_code == 422 + + async def test_resume_wizard_after_admin_created(env: Any) -> None: c, _srv, home = env pw = read_password(Path.home()) @@ -324,6 +379,15 @@ async def test_finish_saves_provider_with_admin_jwt(env: Any) -> None: "name": "MiniMax", "enabled": True, "input": ["text"], + "wire_api": "openai_chat_completions", + "context_window": 204_800, + "max_tokens": 131_072, + "reasoning_config": { + "supported": True, + "adapter": "status_only", + "toggle": False, + "default_mode": "enabled", + }, } ], } @@ -334,6 +398,11 @@ async def test_finish_saves_provider_with_admin_jwt(env: Any) -> None: providers = srv.services.provider_repo.list_all() assert len(providers) == 1 assert providers[0].api_key == "sk-test" + saved_model = providers[0].get_models()[0] + assert saved_model["wire_api"] == "openai_chat_completions" + assert saved_model["context_window"] == 204_800 + assert saved_model["max_tokens"] == 131_072 + assert saved_model["reasoning_config"]["adapter"] == "status_only" provider_name, model_id = srv.services.settings_repo.get_active_model() assert provider_name == "HAI" assert model_id == "MiniMax-M2.7" diff --git a/tests/unit/agents/test_provider_store.py b/tests/unit/agents/test_provider_store.py index df4b3183..522e0407 100644 --- a/tests/unit/agents/test_provider_store.py +++ b/tests/unit/agents/test_provider_store.py @@ -145,6 +145,66 @@ def test_build_harness_configs_maps_context_window_to_max_input_tokens( assert providers[0].models[0].max_input_tokens == 1_000_000 +def test_build_harness_configs_preserves_model_wire_and_output_metadata( + store: ProviderStore, +) -> None: + store._provider_repo.create( + name="opencode-go", + kind="openai", + base_url="https://opencode.ai/zen/go/v1", + api_key="sk-test", + models_json=json.dumps( + [ + { + "id": "qwen3.8-max", + "name": "Qwen 3.8 Max", + "enabled": True, + "wire_api": "anthropic_messages", + "endpoint_base_url": "https://opencode.ai/zen/go", + "context_window": 1_000_000, + # OCTOP persists this UI alias for output capacity. + "max_tokens": 131_072, + } + ] + ), + ) + + model = store.build_harness_configs()[0].models[0] + assert model.wire_api == "anthropic_messages" + assert model.endpoint_base_url == "https://opencode.ai/zen/go" + assert model.context_window == 1_000_000 + assert model.max_output_tokens == 131_072 + + +@pytest.mark.parametrize( + ("model_id", "expected_wire", "expected_endpoint"), + [ + ("grok-4.5", "openai_responses", ""), + ("grok-4.6", "openai_responses", ""), + ("qwen3.8-max", "anthropic_messages", "https://opencode.ai/zen/go"), + ("minimax-m3", "anthropic_messages", "https://opencode.ai/zen/go"), + ("deepseek-v4-pro", None, ""), + ], +) +def test_build_harness_configs_infers_legacy_opencode_go_routes( + store: ProviderStore, + model_id: str, + expected_wire: str | None, + expected_endpoint: str, +) -> None: + store._provider_repo.create( + name="legacy-opencode-go", + kind="openai", + base_url="https://opencode.ai/zen/go/v1", + api_key="sk-test", + models_json=json.dumps([{"id": model_id, "name": model_id, "enabled": True}]), + ) + + model = store.build_harness_configs()[0].models[0] + assert model.wire_api == expected_wire + assert model.endpoint_base_url == expected_endpoint + + def test_resolve_default_model_returns_none_when_stale(store: ProviderStore) -> None: store._provider_repo.create( name="hai", diff --git a/tests/unit/test_provider_preset_expansion.py b/tests/unit/test_provider_preset_expansion.py index f81155a5..e16ee625 100644 --- a/tests/unit/test_provider_preset_expansion.py +++ b/tests/unit/test_provider_preset_expansion.py @@ -55,13 +55,8 @@ def test_load_provider_presets_integration() -> None: assert "DeepSeek-V4-Flash" in coding_ids assert "kimi-k2.6" in coding_ids - opencode_ids = { - "opencode-zen-openai", - "opencode-zen-anthropic", - "opencode-go-openai", - "opencode-go-anthropic", - } - assert opencode_ids <= ids + assert {"opencode-zen-openai", "opencode-zen-anthropic", "opencode-go"} <= ids + assert {"opencode-go-openai", "opencode-go-anthropic"}.isdisjoint(ids) zen_oai = next(p for p in presets if p["id"] == "opencode-zen-openai") assert zen_oai["base_url"] == "https://opencode.ai/zen/v1" @@ -73,8 +68,65 @@ def test_load_provider_presets_integration() -> None: assert zen_ant["base_url"] == "https://opencode.ai/zen" assert zen_ant.get("protocol") == "anthropic" - go_oai = next(p for p in presets if p["id"] == "opencode-go-openai") - assert go_oai["base_url"] == "https://opencode.ai/zen/go/v1" + go = next(p for p in presets if p["id"] == "opencode-go") + assert go["name"] == "opencode-go" + assert go["base_url"] == "https://opencode.ai/zen/go/v1" + assert go.get("protocol") == "openai" + assert go.get("provider_group") == "opencode" + assert go.get("provider_variant") == "go" + + by_wire: dict[str, set[str]] = {} + for model in go["models"]: + by_wire.setdefault(model["wire_api"], set()).add(model["id"]) + assert by_wire == { + "openai_responses": { + "grok-4.6", + "gpt-5.6-luna", + "muse-spark-1.2-contributor", + }, + "openai_chat_completions": { + "glm-5.3-flash", + "glm-5.3", + "glm-5.2", + "glm-5.1", + "kimi-k3", + "kimi-k2.7-code", + "kimi-k2.6", + "longcat-2.0", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", + "mimo-v2.5", + "mimo-v2.5-pro", + "hy3", + }, + "anthropic_messages": { + "minimax-m3", + "minimax-m2.7", + "qwen3.8-max", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.6-plus", + }, + } + assert len(go["models"]) == 23 + assert not any(model["id"] == "minimax-m2.5" for model in go["models"]) + + grok = next(m for m in go["models"] if m["id"] == "grok-4.6") + assert grok["context_window"] == 500_000 + assert grok["max_output_tokens"] == 500_000 + assert grok["input"] == ["text", "image"] + assert grok["reasoning_config"]["adapter"] == "openai_reasoning_effort" + + luna = next(m for m in go["models"] if m["id"] == "gpt-5.6-luna") + assert luna["max_input_tokens"] == 922_000 + + glm52 = next(m for m in go["models"] if m["id"] == "glm-5.2") + assert glm52["max_input_tokens"] == 262_144 + assert glm52["context_window"] == 1_000_000 + + minimax_m3 = next(m for m in go["models"] if m["id"] == "minimax-m3") + assert minimax_m3.get("input", ["text"]) == ["text"] - go_ant = next(p for p in presets if p["id"] == "opencode-go-anthropic") - assert go_ant["base_url"] == "https://opencode.ai/zen/go" + qwen = next(m for m in go["models"] if m["id"] == "qwen3.8-max") + assert "endpoint_base_url" not in qwen diff --git a/tests/unit/test_provider_probe.py b/tests/unit/test_provider_probe.py index 7ca1e43c..4b546107 100644 --- a/tests/unit/test_provider_probe.py +++ b/tests/unit/test_provider_probe.py @@ -33,6 +33,33 @@ def test_build_chat_model_includes_provider_id_and_model_name() -> None: assert model.name == "MiniMax-M2.7" +def test_build_chat_model_preserves_mixed_protocol_model_metadata() -> None: + row = SimpleNamespace( + name="opencode-go", + kind="openai", + base_url="https://opencode.ai/zen/go/v1", + api_key="sk-test", + get_models=lambda: [ + { + "id": "qwen3.8-max", + "name": "Qwen 3.8 Max", + "wire_api": "anthropic_messages", + "endpoint_base_url": "https://opencode.ai/zen/go", + "max_tokens": 131_072, + } + ], + ) + + with patch("harness_agent.llm.factory.build_chat_model") as mock_build: + mock_build.return_value = object() + _build_chat_model(row, model_id="qwen3.8-max") + + _provider, model = mock_build.call_args[0] + assert model.wire_api == "anthropic_messages" + assert model.endpoint_base_url == "https://opencode.ai/zen/go" + assert model.max_output_tokens == 131_072 + + def _embedding_row(**overrides: Any) -> SimpleNamespace: models = overrides.pop( "models", diff --git a/uv.lock b/uv.lock index b0cf647a..3175e6a5 100644 --- a/uv.lock +++ b/uv.lock @@ -2570,7 +2570,7 @@ requires-dist = [ { name = "mcp", specifier = ">=1.9,<2" }, { name = "mss", marker = "extra == 'desktop'", specifier = ">=9.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, - { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=0.9.27" }, + { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=0.9.28" }, { name = "pillow", specifier = ">=10.0" }, { name = "playwright", specifier = ">=1.40" }, { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" },