Skip to content

Commit 61c8d06

Browse files
stubbiclaude
andauthored
feat(server): env-driven adapter model list for the model picker (#107)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4764b35 commit 61c8d06

3 files changed

Lines changed: 75 additions & 0 deletions

File tree

server/src/adapters/registry.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
AdapterRuntimeCommandSpec,
55
ServerAdapterModule,
66
} from "./types.js";
7+
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
78
import {
89
buildSandboxNpmInstallCommand,
910
getAdapterSessionManagement,
@@ -655,6 +656,10 @@ export function getServerAdapter(type: string): ServerAdapterModule {
655656
}
656657

657658
export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> {
659+
const declaredModels = parseAdapterModelsEnv();
660+
if (declaredModels && declaredModels[type]?.length) {
661+
return declaredModels[type].map((m) => ({ id: m.id, label: m.label ?? m.id }));
662+
}
658663
const adapter = findActiveServerAdapter(type);
659664
if (!adapter) return [];
660665
if (adapter.listModels) {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseAdapterModelsEnv } from "./adapter-models-env.js";
3+
4+
const ENV = JSON.stringify({
5+
opencode_local: [
6+
{ id: "tensorix/deepseek/deepseek-chat-v3.1", label: "DeepSeek v3.1" },
7+
{ id: "tensorix/z-ai/glm-4.7", label: "GLM 4.7" },
8+
],
9+
});
10+
11+
describe("parseAdapterModelsEnv", () => {
12+
it("returns null when unset", () => {
13+
expect(parseAdapterModelsEnv({})).toBeNull();
14+
});
15+
it("parses the per-adapter model map", () => {
16+
const m = parseAdapterModelsEnv({ PAPERCLIP_ADAPTER_MODELS: ENV });
17+
expect(m?.opencode_local?.[0]).toEqual({ id: "tensorix/deepseek/deepseek-chat-v3.1", label: "DeepSeek v3.1" });
18+
expect(m?.opencode_local?.length).toBe(2);
19+
});
20+
it("defaults label to id when omitted", () => {
21+
const m = parseAdapterModelsEnv({ PAPERCLIP_ADAPTER_MODELS: JSON.stringify({ pi_local: [{ id: "tensorix/x/y" }] }) });
22+
expect(m?.pi_local?.[0]).toEqual({ id: "tensorix/x/y", label: "tensorix/x/y" });
23+
});
24+
it("throws on invalid JSON (fail loud)", () => {
25+
expect(() => parseAdapterModelsEnv({ PAPERCLIP_ADAPTER_MODELS: "{bad" })).toThrow(/PAPERCLIP_ADAPTER_MODELS/);
26+
});
27+
it("throws when an entry lacks a string id", () => {
28+
expect(() => parseAdapterModelsEnv({ PAPERCLIP_ADAPTER_MODELS: JSON.stringify({ a: [{ label: "x" }] }) })).toThrow();
29+
});
30+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export interface AdapterModelEntry {
2+
id: string;
3+
label?: string;
4+
}
5+
6+
/**
7+
* Per-adapter model list supplied by the operator via env, so the agent model
8+
* picker can offer models the server cannot CLI-discover (e.g. gateway models).
9+
* JSON object: adapterType -> [{ id, label? }]. Returns null when unset; throws
10+
* loudly on malformed input.
11+
*/
12+
export function parseAdapterModelsEnv(
13+
env: Record<string, string | undefined> = process.env,
14+
): Record<string, AdapterModelEntry[]> | null {
15+
const raw = env.PAPERCLIP_ADAPTER_MODELS?.trim();
16+
if (!raw) return null;
17+
let parsed: unknown;
18+
try {
19+
parsed = JSON.parse(raw);
20+
} catch (e) {
21+
throw new Error(`PAPERCLIP_ADAPTER_MODELS must be valid JSON: ${(e as Error).message}`);
22+
}
23+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
24+
throw new Error("PAPERCLIP_ADAPTER_MODELS must be a JSON object mapping adapterType to an array of {id,label}");
25+
}
26+
const out: Record<string, AdapterModelEntry[]> = {};
27+
for (const [type, list] of Object.entries(parsed as Record<string, unknown>)) {
28+
if (!Array.isArray(list)) {
29+
throw new Error(`PAPERCLIP_ADAPTER_MODELS[${type}] must be an array`);
30+
}
31+
out[type] = list.map((m) => {
32+
const o = m as Record<string, unknown>;
33+
if (typeof o.id !== "string" || !o.id) {
34+
throw new Error(`PAPERCLIP_ADAPTER_MODELS[${type}] entries require a non-empty string id`);
35+
}
36+
return { id: o.id, label: typeof o.label === "string" ? o.label : o.id };
37+
});
38+
}
39+
return out;
40+
}

0 commit comments

Comments
 (0)