Skip to content
Open
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
34 changes: 26 additions & 8 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3979,11 +3979,11 @@ <h2>Custom providers</h2>
placeholder="Write-only; blank on edit keeps the stored key"
/></label>
<label
>Models (one per line: id | name | context | maxTokens)
>Models (one per line: id | name | context | maxTokens | input modalities | input $/M | output $/M)
<textarea
id="custom-provider-models"
rows="3"
placeholder="deepseek-chat | DeepSeek V3.2 | 128000 | 8192"
placeholder="deepseek-chat | DeepSeek V3.2 | 128000 | 8192&#10;vision-model | Vision Model | 128000 | 8192 | text,image | 0 | 0"
></textarea>
</label>
<label class="inline"
Expand Down Expand Up @@ -7345,14 +7345,36 @@ <h2 id="governance-review-title">Confirm governance change</h2>
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [id, name, contextWindow, maxTokens] = line.split("|").map((part) => part.trim());
const [id, name, contextWindow, maxTokens, modalities, input, output] = line
.split("|")
.map((part) => part.trim());
const model = { id };
if (name) model.name = name;
if (contextWindow) model.contextWindow = Number(contextWindow);
if (maxTokens) model.maxTokens = Number(maxTokens);
if (modalities) model.modalities = modalities.split(",").map((part) => part.trim());
if (input) model.input = Number(input);
if (output) model.output = Number(output);
return model;
});
}
function formatCustomModels(models) {
return models
.map((model) => {
const parts = [
model.id,
model.name,
model.contextWindow,
model.maxTokens,
Array.isArray(model.modalities) ? model.modalities.join(",") : "",
model.input,
model.output,
];
while (parts.length > 1 && (parts[parts.length - 1] == null || parts[parts.length - 1] === "")) parts.pop();
return parts.map((part) => part ?? "").join(" | ");
})
.join("\n");
}
async function loadCustomProviders() {
const res = await api("GET", "/api/custom-providers");
if (!res.ok) return;
Expand Down Expand Up @@ -7383,11 +7405,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
$("custom-provider-protocol").value = provider.protocol;
$("custom-provider-url").value = provider.baseUrl;
$("custom-provider-key").value = "";
$("custom-provider-models").value = provider.models
.map((model) =>
[model.id, model.name, model.contextWindow, model.maxTokens].filter((part) => part != null).join(" | "),
)
.join("\n");
$("custom-provider-models").value = formatCustomModels(provider.models);
};
const remove = document.createElement("button");
remove.className = "danger";
Expand Down
29 changes: 29 additions & 0 deletions plugins/admin/test/onboarding-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ function resolveView(pathname: string, search: string): string {
return vm.runInContext(src, context);
}

function customModelFields(): {
parseCustomModels(text: string): unknown[];
formatCustomModels(models: unknown[]): string;
} {
const source = slice("function parseCustomModels(text) {", "async function loadCustomProviders()");
const context = vm.createContext({});
return vm.runInContext(`${source}; ({ parseCustomModels, formatCustomModels })`, context);
}

test("onboarding is a navigable view", () => {
assert.match(html, /\{ label: "Admin", views: \["onboarding",/);
});
Expand All @@ -43,3 +52,23 @@ test("?view=onboarding resolves to the onboarding view", () => {
test("unknown views still fall back to the default view", () => {
assert.equal(resolveView("/admin/no-such-view", ""), "history");
});

test("custom model fields preserve optional positions, modalities, and pricing", () => {
const { parseCustomModels, formatCustomModels } = customModelFields();
const models = [
{
id: "vision-model",
contextWindow: 128000,
maxTokens: 8192,
modalities: ["text", "image"],
input: 1.25,
output: 4.5,
},
];
const formatted = formatCustomModels(models);
assert.equal(formatted, "vision-model | | 128000 | 8192 | text,image | 1.25 | 4.5");
assert.deepEqual(JSON.parse(JSON.stringify(parseCustomModels(formatted))), models);
assert.deepEqual(JSON.parse(JSON.stringify(parseCustomModels("legacy | Legacy | 64000 | 4096"))), [
{ id: "legacy", name: "Legacy", contextWindow: 64000, maxTokens: 4096 },
]);
});
3 changes: 2 additions & 1 deletion src/harness/opencode-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { pathToFileURL } from "node:url";
import { spawn, type ChildProcess } from "node:child_process";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { CONFIG_DEFAULTS, type Config } from "../config.ts";
import { isCustomModelId } from "../model/custom-providers.ts";
import { customModelInputModalities, isCustomModelId } from "../model/custom-providers.ts";
import type { CustomProviderSpec } from "../model/custom-providers.ts";
import { DEFAULT_AGENT_MODEL_ID, resolveModel } from "../model/pi-models.ts";
import { startSignalPoll, type RunSignalStore } from "../runs/run-signal-store.ts";
Expand Down Expand Up @@ -670,6 +670,7 @@ export function createOpenCodeHarness(opts: OpenCodeHarnessOptions = {}): Harnes
m.id,
{
name: m.name ?? m.id,
modalities: { input: customModelInputModalities(m), output: ["text"] },
...(m.contextWindow || m.maxTokens
? {
limit: {
Expand Down
9 changes: 7 additions & 2 deletions src/model/custom-provider-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import { decryptSecret, deriveConnectorKey, encryptSecret } from "../connectors/connector-client-store.ts";
import type { DurableMap } from "../persistence/durable-map.ts";
import { validateCustomProviderSpec, type CustomProviderSpec } from "./custom-providers.ts";
import { customModelInputModalities, validateCustomProviderSpec, type CustomProviderSpec } from "./custom-providers.ts";

export interface StoredCustomProvider extends CustomProviderSpec {
apiKeyEnc?: string;
Expand Down Expand Up @@ -42,7 +42,12 @@ function strip(saved: StoredCustomProvider): CustomProviderSpec {
name: saved.name,
protocol: saved.protocol,
baseUrl: saved.baseUrl,
models: saved.models,
models: saved.models.map((model) => {
const stored = model as typeof model & { modalities?: unknown };
return stored.modalities === undefined
? { ...model }
: { ...model, modalities: customModelInputModalities(stored) };
}),
};
}

Expand Down
24 changes: 23 additions & 1 deletion src/model/custom-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface CustomModelSpec {
name?: string;
contextWindow?: number;
maxTokens?: number;
modalities?: ("text" | "image")[];
/** USD per million input tokens. Defaults to 0 (unknown / not metered). */
input?: number;
/** USD per million output tokens. Defaults to 0. */
Expand Down Expand Up @@ -63,6 +64,9 @@ export function validateCustomProviderSpec(spec: CustomProviderSpec): void {
throw new Error(`model "${m.id}": name must be a string of 200 chars or fewer`);
if (seen.has(m.id)) throw new Error(`duplicate model id "${m.id}"`);
seen.add(m.id);
if (m.modalities !== undefined && !isCustomModelInputModalities(m.modalities)) {
throw new Error(`model "${m.id}": modalities must contain text and optional image exactly once`);
}
for (const [field, v] of [
["contextWindow", m.contextWindow],
["maxTokens", m.maxTokens],
Expand Down Expand Up @@ -97,6 +101,23 @@ export interface CustomRuntimeModel {
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 8_192;

function isCustomModelInputModalities(value: unknown): value is ("text" | "image")[] {
return (
Array.isArray(value) &&
value.includes("text") &&
new Set(value).size === value.length &&
value.every((modality) => modality === "text" || modality === "image")
);
}

export function customModelInputModalities(model: { modalities?: unknown }): ("text" | "image")[] {
const value =
model.modalities && typeof model.modalities === "object" && !Array.isArray(model.modalities)
? (model.modalities as { input?: unknown }).input
: model.modalities;
return isCustomModelInputModalities(value) ? [...value] : ["text"];
}

function toRuntimeModel(provider: CustomProviderSpec, m: CustomModelSpec): CustomRuntimeModel {
return {
id: m.id,
Expand All @@ -105,7 +126,7 @@ function toRuntimeModel(provider: CustomProviderSpec, m: CustomModelSpec): Custo
api: provider.protocol === "anthropic" ? "anthropic-messages" : "openai-completions",
baseUrl: provider.baseUrl,
reasoning: false,
input: ["text"],
input: customModelInputModalities(m),
cost: { input: m.input ?? 0, output: m.output ?? 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: m.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
maxTokens: m.maxTokens ?? DEFAULT_MAX_TOKENS,
Expand Down Expand Up @@ -173,6 +194,7 @@ export function customModelsJson(): { providers: Record<string, unknown> } | und
name: m.name ?? m.id,
contextWindow: m.contextWindow ?? 128_000,
maxTokens: m.maxTokens ?? 8_192,
input: customModelInputModalities(m),
cost: { input: m.input ?? 0, output: m.output ?? 0, cacheRead: 0, cacheWrite: 0 },
})),
},
Expand Down
52 changes: 47 additions & 5 deletions test/custom-provider-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { test } from "node:test";
import { createInsecureTestServer } from "../src/api/server.ts";
import { buildApp } from "../src/wiring.ts";
import { testConfig } from "./support/test-config.ts";
import { oneShot } from "../src/harness/pi-harness.ts";
import { createPiHarness, oneShot } from "../src/harness/pi-harness.ts";
import type { HarnessTurnInput } from "../src/harness/harness.ts";
import { resolveModel, modelSupportedByHarness, modelServiceable } from "../src/model/pi-models.ts";
import { setCustomProviders } from "../src/model/custom-providers.ts";
import { createCustomProviderStore } from "../src/model/custom-provider-store.ts";
Expand All @@ -24,7 +25,7 @@ const ADMIN = { "content-type": "application/json", "x-admin-actor": "admin-alic

test("QA: full custom-provider lifecycle against a live fake upstream", async () => {
// --- fake OpenAI-compatible upstream ---
const seen: Array<{ path: string; auth: string | undefined; model?: string }> = [];
const seen: Array<{ path: string; auth: string | undefined; model?: string; body?: unknown }> = [];
const upstream = createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c));
Expand All @@ -40,7 +41,8 @@ test("QA: full custom-provider lifecycle against a live fake upstream", async ()
return res.end(JSON.stringify({ data: [{ id: "qa-chat" }] }));
}
if (req.url?.endsWith("/chat/completions")) {
record.model = (JSON.parse(body) as { model?: string }).model;
record.body = JSON.parse(body);
record.model = (record.body as { model?: string }).model;
seen.push(record);
res.writeHead(200, { "content-type": "text/event-stream" });
const chunk = (delta: object, finish: string | null) =>
Expand Down Expand Up @@ -127,7 +129,17 @@ test("QA: full custom-provider lifecycle against a live fake upstream", async ()
protocol: "openai",
baseUrl: upstreamUrl,
apiKey: "sk-qa-good",
models: [{ id: "qa-chat", name: "QA Chat", contextWindow: 64000, maxTokens: 4096 }],
models: [
{
id: "qa-chat",
name: "QA Chat",
contextWindow: 64000,
maxTokens: 4096,
modalities: ["text", "image"],
input: 1.5,
output: 6,
},
],
}),
});
assert.equal(r.status, 200);
Expand Down Expand Up @@ -166,14 +178,44 @@ test("QA: full custom-provider lifecycle against a live fake upstream", async ()
assert.equal(call!.model, "qa-chat");
assert.equal(call!.auth, "Bearer sk-qa-good", "stored key was sent to the custom endpoint");

const imageHarness = createPiHarness({
modelId: "qa-chat",
resolveProviderKeys: async () => ({ qa: "sk-qa-good" }),
captureRequests: false,
turnWallClockMs: 5_000,
});
const imageData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
const imageTurn = {
session: { id: "qa-image" } as HarnessTurnInput["session"],
input: "describe this image",
images: [{ mimeType: "image/png", dataBase64: imageData }],
model: "qa-chat",
systemPrompt: "be concise",
history: [],
tools: {} as HarnessTurnInput["tools"],
scopeLabel: "org:test" as HarnessTurnInput["scopeLabel"],
orgScopeId: "org:test" as HarnessTurnInput["orgScopeId"],
emit: async (entry) =>
({ ...entry, seq: 1, sessionId: "qa-image", createdAt: Date.now() }) as Awaited<
ReturnType<HarnessTurnInput["emit"]>
>,
recordModelCall: () => {},
} satisfies HarnessTurnInput;
const imageReply = await imageHarness.turns.runTurn(imageTurn);
assert.equal(imageReply.reply, "QA UPSTREAM REPLY");
const imageCall = seen.filter((item) => item.path.endsWith("/chat/completions")).at(-1);
const imagePayload = JSON.stringify(imageCall?.body);
assert.ok(imagePayload.includes(`data:image/png;base64,${imageData}`));
assert.doesNotMatch(imagePayload, /image omitted: model does not support images/);

// 7. edit WITHOUT key keeps the stored key
r = await api("/v1/admin/custom-providers/qa", {
method: "PUT",
body: JSON.stringify({
name: "QA Provider v2",
protocol: "openai",
baseUrl: upstreamUrl,
models: [{ id: "qa-chat" }],
models: [{ id: "qa-chat", modalities: ["text", "image"], input: 1.5, output: 6 }],
}),
});
assert.equal(r.status, 200);
Expand Down
Loading