refactor(agents): Dynamically determine available OpenCode Zen models
Wires up the previously unused discoverOpencodeZenModels() function to actually fetch models dynamically from the OpenCode Zen API, matching the pattern used by other providers (Venice, Bedrock). The codebase already had infrastructure for dynamic OpenCode Zen model discovery (discoverOpencodeZenModels() in opencode-zen-models.ts), but it was never actually called from the provider configuration. The system was falling back to a hard-coded static catalog that was already out of date.
This commit is contained in:
parent
da71eaebd2
commit
fe7b1d3b1b
@ -13,6 +13,7 @@ import {
|
|||||||
SYNTHETIC_MODEL_CATALOG,
|
SYNTHETIC_MODEL_CATALOG,
|
||||||
} from "./synthetic-models.js";
|
} from "./synthetic-models.js";
|
||||||
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
|
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
|
||||||
|
import { discoverOpencodeZenModels, OPENCODE_ZEN_API_BASE_URL } from "./opencode-zen-models.js";
|
||||||
|
|
||||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||||
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||||
@ -388,6 +389,16 @@ async function buildOllamaProvider(): Promise<ProviderConfig> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildOpencodeZenProvider(apiKey: string): Promise<ProviderConfig> {
|
||||||
|
const models = await discoverOpencodeZenModels(apiKey);
|
||||||
|
return {
|
||||||
|
baseUrl: OPENCODE_ZEN_API_BASE_URL,
|
||||||
|
api: "openai-completions",
|
||||||
|
models,
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveImplicitProviders(params: {
|
export async function resolveImplicitProviders(params: {
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
}): Promise<ModelsConfig["providers"]> {
|
}): Promise<ModelsConfig["providers"]> {
|
||||||
@ -414,7 +425,10 @@ export async function resolveImplicitProviders(params: {
|
|||||||
resolveEnvApiKeyVarName("kimi-code") ??
|
resolveEnvApiKeyVarName("kimi-code") ??
|
||||||
resolveApiKeyFromProfiles({ provider: "kimi-code", store: authStore });
|
resolveApiKeyFromProfiles({ provider: "kimi-code", store: authStore });
|
||||||
if (kimiCodeKey) {
|
if (kimiCodeKey) {
|
||||||
providers["kimi-code"] = { ...buildKimiCodeProvider(), apiKey: kimiCodeKey };
|
providers["kimi-code"] = {
|
||||||
|
...buildKimiCodeProvider(),
|
||||||
|
apiKey: kimiCodeKey,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const syntheticKey =
|
const syntheticKey =
|
||||||
@ -454,6 +468,14 @@ export async function resolveImplicitProviders(params: {
|
|||||||
providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey };
|
providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenCode Zen provider - dynamically fetches models from API
|
||||||
|
const opencodeKey =
|
||||||
|
resolveEnvApiKeyVarName("opencode") ??
|
||||||
|
resolveApiKeyFromProfiles({ provider: "opencode", store: authStore });
|
||||||
|
if (opencodeKey) {
|
||||||
|
providers.opencode = await buildOpencodeZenProvider(opencodeKey);
|
||||||
|
}
|
||||||
|
|
||||||
return providers;
|
return providers;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,7 +484,9 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}): Promise<ProviderConfig | null> {
|
}): Promise<ProviderConfig | null> {
|
||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
|
const authStore = ensureAuthProfileStore(params.agentDir, {
|
||||||
|
allowKeychainPrompt: false,
|
||||||
|
});
|
||||||
const hasProfile = listProfilesForProvider(authStore, "github-copilot").length > 0;
|
const hasProfile = listProfilesForProvider(authStore, "github-copilot").length > 0;
|
||||||
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
|
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
|
||||||
const githubToken = (envToken ?? "").trim();
|
const githubToken = (envToken ?? "").trim();
|
||||||
@ -527,7 +551,10 @@ export async function resolveImplicitBedrockProvider(params: {
|
|||||||
if (enabled !== true && !hasAwsCreds) return null;
|
if (enabled !== true && !hasAwsCreds) return null;
|
||||||
|
|
||||||
const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
|
const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
|
||||||
const models = await discoverBedrockModels({ region, config: discoveryConfig });
|
const models = await discoverBedrockModels({
|
||||||
|
region,
|
||||||
|
config: discoveryConfig,
|
||||||
|
});
|
||||||
if (models.length === 0) return null;
|
if (models.length === 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getOpencodeZenStaticFallbackModels,
|
|
||||||
OPENCODE_ZEN_MODEL_ALIASES,
|
OPENCODE_ZEN_MODEL_ALIASES,
|
||||||
|
OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS,
|
||||||
resolveOpencodeZenAlias,
|
resolveOpencodeZenAlias,
|
||||||
resolveOpencodeZenModelApi,
|
resolveOpencodeZenModelApi,
|
||||||
} from "./opencode-zen-models.js";
|
} from "./opencode-zen-models.js";
|
||||||
@ -50,16 +50,14 @@ describe("resolveOpencodeZenModelApi", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getOpencodeZenStaticFallbackModels", () => {
|
describe("OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS", () => {
|
||||||
it("returns an array of models", () => {
|
it("is an array of model definitions", () => {
|
||||||
const models = getOpencodeZenStaticFallbackModels();
|
expect(Array.isArray(OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS)).toBe(true);
|
||||||
expect(Array.isArray(models)).toBe(true);
|
expect(OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS.length).toBe(9);
|
||||||
expect(models.length).toBe(9);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes Claude, GPT, Gemini, and GLM models", () => {
|
it("includes Claude, GPT, Gemini, and GLM models", () => {
|
||||||
const models = getOpencodeZenStaticFallbackModels();
|
const ids = OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS.map((m) => m.id);
|
||||||
const ids = models.map((m) => m.id);
|
|
||||||
|
|
||||||
expect(ids).toContain("claude-opus-4-5");
|
expect(ids).toContain("claude-opus-4-5");
|
||||||
expect(ids).toContain("gpt-5.2");
|
expect(ids).toContain("gpt-5.2");
|
||||||
@ -68,9 +66,8 @@ describe("getOpencodeZenStaticFallbackModels", () => {
|
|||||||
expect(ids).toContain("glm-4.7");
|
expect(ids).toContain("glm-4.7");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns valid ModelDefinitionConfig objects", () => {
|
it("contains valid ModelDefinitionConfig objects", () => {
|
||||||
const models = getOpencodeZenStaticFallbackModels();
|
for (const model of OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS) {
|
||||||
for (const model of models) {
|
|
||||||
expect(model.id).toBeDefined();
|
expect(model.id).toBeDefined();
|
||||||
expect(model.name).toBeDefined();
|
expect(model.name).toBeDefined();
|
||||||
expect(typeof model.reasoning).toBe("boolean");
|
expect(typeof model.reasoning).toBe("boolean");
|
||||||
|
|||||||
@ -8,16 +8,136 @@
|
|||||||
* Auth URL: https://opencode.ai/auth
|
* Auth URL: https://opencode.ai/auth
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import type { ModelApi, ModelDefinitionConfig } from "../config/types.js";
|
import type { ModelApi, ModelDefinitionConfig } from "../config/types.js";
|
||||||
|
|
||||||
export const OPENCODE_ZEN_API_BASE_URL = "https://opencode.ai/zen/v1";
|
export const OPENCODE_ZEN_API_BASE_URL = "https://opencode.ai/zen/v1";
|
||||||
export const OPENCODE_ZEN_DEFAULT_MODEL = "claude-opus-4-5";
|
export const OPENCODE_ZEN_DEFAULT_MODEL = "claude-opus-4-5";
|
||||||
export const OPENCODE_ZEN_DEFAULT_MODEL_REF = `opencode/${OPENCODE_ZEN_DEFAULT_MODEL}`;
|
export const OPENCODE_ZEN_DEFAULT_MODEL_REF = `opencode/${OPENCODE_ZEN_DEFAULT_MODEL}`;
|
||||||
|
|
||||||
// Cache for fetched models (1 hour TTL)
|
export const OPENCODE_ZEN_DEFAULT_COST = {
|
||||||
let cachedModels: ModelDefinitionConfig[] | null = null;
|
input: 0,
|
||||||
let cacheTimestamp = 0;
|
output: 0,
|
||||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static catalog of known OpenCode Zen models with metadata.
|
||||||
|
* Serves as fallback when API is unreachable and for enriching
|
||||||
|
* API-discovered models with known metadata.
|
||||||
|
*/
|
||||||
|
export const OPENCODE_ZEN_STATIC_CATALOG = [
|
||||||
|
{
|
||||||
|
id: "gpt-5.1-codex",
|
||||||
|
name: "GPT-5.1 Codex",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"] as Array<"text" | "image">,
|
||||||
|
cost: { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
|
||||||
|
contextWindow: 400000,
|
||||||
|
maxTokens: 128000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4-5",
|
||||||
|
name: "Claude Opus 4.5",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"] as Array<"text" | "image">,
|
||||||
|
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 64000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3-pro",
|
||||||
|
name: "Gemini 3 Pro",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"] as Array<"text" | "image">,
|
||||||
|
cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
|
||||||
|
contextWindow: 1048576,
|
||||||
|
maxTokens: 65536,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.1-codex-mini",
|
||||||
|
name: "GPT-5.1 Codex Mini",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"] as Array<"text" | "image">,
|
||||||
|
cost: { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 },
|
||||||
|
contextWindow: 400000,
|
||||||
|
maxTokens: 128000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.1",
|
||||||
|
name: "GPT-5.1",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"] as Array<"text" | "image">,
|
||||||
|
cost: { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
|
||||||
|
contextWindow: 400000,
|
||||||
|
maxTokens: 128000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "glm-4.7",
|
||||||
|
name: "GLM-4.7",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"] as Array<"text" | "image">,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 204800,
|
||||||
|
maxTokens: 131072,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3-flash",
|
||||||
|
name: "Gemini 3 Flash",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"] as Array<"text" | "image">,
|
||||||
|
cost: { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
|
||||||
|
contextWindow: 1048576,
|
||||||
|
maxTokens: 65536,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.1-codex-max",
|
||||||
|
name: "GPT-5.1 Codex Max",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"] as Array<"text" | "image">,
|
||||||
|
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
|
||||||
|
contextWindow: 400000,
|
||||||
|
maxTokens: 128000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-5.2",
|
||||||
|
name: "GPT-5.2",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"] as Array<"text" | "image">,
|
||||||
|
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
|
||||||
|
contextWindow: 400000,
|
||||||
|
maxTokens: 128000,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS: ModelDefinitionConfig[] =
|
||||||
|
OPENCODE_ZEN_STATIC_CATALOG.map(buildOpencodeZenModelDefinition);
|
||||||
|
|
||||||
|
const OPENCODE_ZEN_CATALOG_BY_ID = new Map<string, OpencodeZenCatalogEntry>(
|
||||||
|
OPENCODE_ZEN_STATIC_CATALOG.map((entry) => [entry.id, entry]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type OpencodeZenCatalogEntry = (typeof OPENCODE_ZEN_STATIC_CATALOG)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a ModelDefinitionConfig from a catalog entry.
|
||||||
|
*/
|
||||||
|
export function buildOpencodeZenModelDefinition(
|
||||||
|
entry: OpencodeZenCatalogEntry,
|
||||||
|
): ModelDefinitionConfig {
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
name: entry.name,
|
||||||
|
api: resolveOpencodeZenModelApi(entry.id),
|
||||||
|
reasoning: entry.reasoning,
|
||||||
|
input: [...entry.input],
|
||||||
|
cost: entry.cost,
|
||||||
|
contextWindow: entry.contextWindow,
|
||||||
|
maxTokens: entry.maxTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Model aliases for convenient shortcuts.
|
* Model aliases for convenient shortcuts.
|
||||||
@ -100,6 +220,7 @@ export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a model supports image input.
|
* Check if a model supports image input.
|
||||||
|
* Used as fallback for unknown models from the API.
|
||||||
*/
|
*/
|
||||||
function supportsImageInput(modelId: string): boolean {
|
function supportsImageInput(modelId: string): boolean {
|
||||||
const lower = modelId.toLowerCase();
|
const lower = modelId.toLowerCase();
|
||||||
@ -109,105 +230,14 @@ function supportsImageInput(modelId: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODEL_COSTS: Record<
|
|
||||||
string,
|
|
||||||
{ input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
||||||
> = {
|
|
||||||
"gpt-5.1-codex": {
|
|
||||||
input: 1.07,
|
|
||||||
output: 8.5,
|
|
||||||
cacheRead: 0.107,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
||||||
"gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
|
|
||||||
"gpt-5.1-codex-mini": {
|
|
||||||
input: 0.25,
|
|
||||||
output: 2,
|
|
||||||
cacheRead: 0.025,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
"gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
|
|
||||||
"glm-4.7": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
|
|
||||||
"gpt-5.1-codex-max": {
|
|
||||||
input: 1.25,
|
|
||||||
output: 10,
|
|
||||||
cacheRead: 0.125,
|
|
||||||
cacheWrite: 0,
|
|
||||||
},
|
|
||||||
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
||||||
|
|
||||||
const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|
||||||
"gpt-5.1-codex": 400000,
|
|
||||||
"claude-opus-4-5": 200000,
|
|
||||||
"gemini-3-pro": 1048576,
|
|
||||||
"gpt-5.1-codex-mini": 400000,
|
|
||||||
"gpt-5.1": 400000,
|
|
||||||
"glm-4.7": 204800,
|
|
||||||
"gemini-3-flash": 1048576,
|
|
||||||
"gpt-5.1-codex-max": 400000,
|
|
||||||
"gpt-5.2": 400000,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDefaultContextWindow(modelId: string): number {
|
|
||||||
return MODEL_CONTEXT_WINDOWS[modelId] ?? 128000;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODEL_MAX_TOKENS: Record<string, number> = {
|
|
||||||
"gpt-5.1-codex": 128000,
|
|
||||||
"claude-opus-4-5": 64000,
|
|
||||||
"gemini-3-pro": 65536,
|
|
||||||
"gpt-5.1-codex-mini": 128000,
|
|
||||||
"gpt-5.1": 128000,
|
|
||||||
"glm-4.7": 131072,
|
|
||||||
"gemini-3-flash": 65536,
|
|
||||||
"gpt-5.1-codex-max": 128000,
|
|
||||||
"gpt-5.2": 128000,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDefaultMaxTokens(modelId: string): number {
|
|
||||||
return MODEL_MAX_TOKENS[modelId] ?? 8192;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a ModelDefinitionConfig from a model ID.
|
|
||||||
*/
|
|
||||||
function buildModelDefinition(modelId: string): ModelDefinitionConfig {
|
|
||||||
return {
|
|
||||||
id: modelId,
|
|
||||||
name: formatModelName(modelId),
|
|
||||||
api: resolveOpencodeZenModelApi(modelId),
|
|
||||||
// Treat Zen models as reasoning-capable so defaults pick thinkLevel="low" unless users opt out.
|
|
||||||
reasoning: true,
|
|
||||||
input: supportsImageInput(modelId) ? ["text", "image"] : ["text"],
|
|
||||||
cost: MODEL_COSTS[modelId] ?? DEFAULT_COST,
|
|
||||||
contextWindow: getDefaultContextWindow(modelId),
|
|
||||||
maxTokens: getDefaultMaxTokens(modelId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a model ID into a human-readable name.
|
* Format a model ID into a human-readable name.
|
||||||
|
* Used as fallback for unknown models from the API.
|
||||||
*/
|
*/
|
||||||
const MODEL_NAMES: Record<string, string> = {
|
|
||||||
"gpt-5.1-codex": "GPT-5.1 Codex",
|
|
||||||
"claude-opus-4-5": "Claude Opus 4.5",
|
|
||||||
"gemini-3-pro": "Gemini 3 Pro",
|
|
||||||
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
|
||||||
"gpt-5.1": "GPT-5.1",
|
|
||||||
"glm-4.7": "GLM-4.7",
|
|
||||||
"gemini-3-flash": "Gemini 3 Flash",
|
|
||||||
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
|
|
||||||
"gpt-5.2": "GPT-5.2",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatModelName(modelId: string): string {
|
function formatModelName(modelId: string): string {
|
||||||
if (MODEL_NAMES[modelId]) {
|
const catalogEntry = OPENCODE_ZEN_CATALOG_BY_ID.get(modelId);
|
||||||
return MODEL_NAMES[modelId];
|
if (catalogEntry) {
|
||||||
|
return catalogEntry.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
return modelId
|
return modelId
|
||||||
@ -216,25 +246,6 @@ function formatModelName(modelId: string): string {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Static fallback models when API is unreachable.
|
|
||||||
*/
|
|
||||||
export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
|
|
||||||
const modelIds = [
|
|
||||||
"gpt-5.1-codex",
|
|
||||||
"claude-opus-4-5",
|
|
||||||
"gemini-3-pro",
|
|
||||||
"gpt-5.1-codex-mini",
|
|
||||||
"gpt-5.1",
|
|
||||||
"glm-4.7",
|
|
||||||
"gemini-3-flash",
|
|
||||||
"gpt-5.1-codex-max",
|
|
||||||
"gpt-5.2",
|
|
||||||
];
|
|
||||||
|
|
||||||
return modelIds.map(buildModelDefinition);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response shape from OpenCode Zen /models endpoint.
|
* Response shape from OpenCode Zen /models endpoint.
|
||||||
* Returns OpenAI-compatible format.
|
* Returns OpenAI-compatible format.
|
||||||
@ -248,18 +259,46 @@ interface ZenModelsResponse {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpencodeZenCacheEntry = {
|
||||||
|
models: ModelDefinitionConfig[];
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch models from the OpenCode Zen API.
|
* Cache for fetched models (1 hour TTL).
|
||||||
* Uses caching with 1-hour TTL.
|
* Scoped by a hashed API key to avoid cross-key leakage.
|
||||||
|
*/
|
||||||
|
const cachedModelsByKey = new Map<string, OpencodeZenCacheEntry>();
|
||||||
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
function hashApiKey(apiKey: string): string {
|
||||||
|
return crypto.createHash("sha256").update(apiKey).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCacheKey(apiKey?: string): string {
|
||||||
|
if (!apiKey) return "public";
|
||||||
|
return `key:${hashApiKey(apiKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover models from the OpenCode Zen API.
|
||||||
|
* Fetches dynamically and merges with static catalog metadata.
|
||||||
*
|
*
|
||||||
* @param apiKey - OpenCode Zen API key for authentication
|
* @param apiKey - OpenCode Zen API key for authentication (optional for discovery)
|
||||||
* @returns Array of model definitions, or static fallback on failure
|
* @returns Array of model definitions, or static fallback on failure
|
||||||
*/
|
*/
|
||||||
export async function fetchOpencodeZenModels(apiKey?: string): Promise<ModelDefinitionConfig[]> {
|
export async function discoverOpencodeZenModels(apiKey?: string): Promise<ModelDefinitionConfig[]> {
|
||||||
|
// Skip API discovery in test environment
|
||||||
|
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
|
||||||
|
return OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS;
|
||||||
|
}
|
||||||
|
|
||||||
// Return cached models if still valid
|
// Return cached models if still valid
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (cachedModels && now - cacheTimestamp < CACHE_TTL_MS) {
|
const cacheKey = resolveCacheKey(apiKey);
|
||||||
return cachedModels;
|
const cachedEntry = cachedModelsByKey.get(cacheKey);
|
||||||
|
if (cachedEntry && now - cachedEntry.timestamp < CACHE_TTL_MS) {
|
||||||
|
return cachedEntry.models;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -273,35 +312,71 @@ export async function fetchOpencodeZenModels(apiKey?: string): Promise<ModelDefi
|
|||||||
const response = await fetch(`${OPENCODE_ZEN_API_BASE_URL}/models`, {
|
const response = await fetch(`${OPENCODE_ZEN_API_BASE_URL}/models`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers,
|
headers,
|
||||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
signal: AbortSignal.timeout(10000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`API returned ${response.status}: ${response.statusText}`);
|
console.warn(
|
||||||
|
`[opencode-zen] Failed to discover models: HTTP ${response.status}, using static catalog`,
|
||||||
|
);
|
||||||
|
return OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as ZenModelsResponse;
|
const data = (await response.json()) as ZenModelsResponse;
|
||||||
|
|
||||||
if (!data.data || !Array.isArray(data.data)) {
|
if (!data.data || !Array.isArray(data.data)) {
|
||||||
throw new Error("Invalid response format from /models endpoint");
|
console.warn(
|
||||||
|
"[opencode-zen] Invalid response format from /models endpoint, using static catalog",
|
||||||
|
);
|
||||||
|
return OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = data.data.map((model) => buildModelDefinition(model.id));
|
const models: ModelDefinitionConfig[] = [];
|
||||||
|
|
||||||
cachedModels = models;
|
for (const apiModel of data.data) {
|
||||||
cacheTimestamp = now;
|
const catalogEntry = OPENCODE_ZEN_CATALOG_BY_ID.get(apiModel.id);
|
||||||
|
|
||||||
|
if (catalogEntry) {
|
||||||
|
// Use rich catalog metadata for known models
|
||||||
|
models.push(buildOpencodeZenModelDefinition(catalogEntry));
|
||||||
|
} else {
|
||||||
|
// Create definition for newly discovered models not in catalog
|
||||||
|
// This allows new models (like kimi-k2.5-free) to appear automatically
|
||||||
|
const hasVision = supportsImageInput(apiModel.id);
|
||||||
|
models.push({
|
||||||
|
id: apiModel.id,
|
||||||
|
name: formatModelName(apiModel.id),
|
||||||
|
api: resolveOpencodeZenModelApi(apiModel.id),
|
||||||
|
// Treat Zen models as reasoning-capable by default
|
||||||
|
reasoning: true,
|
||||||
|
input: hasVision ? ["text", "image"] : ["text"],
|
||||||
|
cost: OPENCODE_ZEN_DEFAULT_COST,
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
cachedModelsByKey.set(cacheKey, { models, timestamp: now });
|
||||||
|
|
||||||
return models;
|
return models;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[opencode-zen] Failed to fetch models, using static fallback: ${String(error)}`);
|
console.warn(`[opencode-zen] Discovery failed: ${String(error)}, using static catalog`);
|
||||||
return getOpencodeZenStaticFallbackModels();
|
return OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the model cache (useful for testing or forcing refresh).
|
* Clear the model cache (useful for testing or forcing refresh).
|
||||||
*/
|
*/
|
||||||
export function clearOpencodeZenModelCache(): void {
|
export async function fetchOpencodeZenModels(apiKey?: string): Promise<ModelDefinitionConfig[]> {
|
||||||
cachedModels = null;
|
return discoverOpencodeZenModels(apiKey);
|
||||||
cacheTimestamp = 0;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the model cache (useful for testing or forcing refresh).
|
||||||
|
*/
|
||||||
|
export function clearOpencodeZenModelCache(): void {
|
||||||
|
cachedModelsByKey.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user