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
6 changes: 5 additions & 1 deletion agents/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
autoSelectModel,
buildReviewerAgentConfig,
geminiHighThinkingOverrides,
qwenProviderConfig,
installOpencodeCli,
type OpenCodeConfig,
} from "./opencodeShared.ts";
Expand Down Expand Up @@ -133,7 +134,10 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
// tools, so we lose only the batch wrapper, not parallelism.
// gemini-3 thinking pinned to high for review depth; gpt and anthropic
// effort set elsewhere (gpt: upstream default, anthropic: --effort flag in claude.ts).
provider: { google: { models: geminiHighThinkingOverrides() } },
provider: {
google: { models: geminiHighThinkingOverrides() },
qwen: qwenProviderConfig(),
},
};

if (model) {
Expand Down
25 changes: 25 additions & 0 deletions agents/opencodeShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ export function geminiHighThinkingOverrides(): Record<string, { options: object
);
}

/**
* OpenAI-compatible Qwen provider for OPENCODE_CONFIG_CONTENT. Models derive from
* the qwen aliases in models.ts; key/baseURL from QWEN_* → DASHSCOPE_* → LLM_*.
*/
export function qwenProviderConfig(): Record<string, unknown> {
const apiKey =
process.env.QWEN_API_KEY ?? process.env.DASHSCOPE_API_KEY ?? process.env.LLM_API_KEY;
const baseURL =
process.env.QWEN_BASE_URL ??
process.env.DASHSCOPE_BASE_URL ??
process.env.LLM_BASE_URL ??
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
const models = Object.fromEntries(
modelAliases
.filter((a) => a.provider === "qwen")
.map((a) => [a.resolve.replace(/^qwen\//, ""), { name: a.displayName }])
);
return {
npm: "@ai-sdk/openai-compatible",
name: "Qwen",
options: { baseURL, ...(apiKey ? { apiKey } : {}) },
models,
};
}

/**
* Read-only `reviewfrog` subagent for lens-based review. Non-mutative +
* non-recursive — enforced by the system prompt in reviewer.ts.
Expand Down
6 changes: 5 additions & 1 deletion agents/opencode_v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
autoSelectModel,
buildReviewerAgentConfig,
geminiHighThinkingOverrides,
qwenProviderConfig,
installOpencodeCli,
type OpenCodeConfig,
} from "./opencodeShared.ts";
Expand Down Expand Up @@ -130,7 +131,10 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
})(),
// gemini-3 thinking pinned to high for review depth; gpt and anthropic
// effort set elsewhere (gpt: upstream default, anthropic: --effort flag in claude.ts).
provider: { google: { models: geminiHighThinkingOverrides() } },
provider: {
google: { models: geminiHighThinkingOverrides() },
qwen: qwenProviderConfig(),
},
};

if (model) {
Expand Down
13 changes: 13 additions & 0 deletions models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ describe("getModelEnvVars", () => {
expect(envVars).toContain("GEMINI_API_KEY");
});

it("returns correct env vars for qwen", () => {
expect(getModelEnvVars("qwen/qwen-coder")).toEqual([
"QWEN_API_KEY",
"DASHSCOPE_API_KEY",
"LLM_API_KEY",
]);
});

it("returns empty array for unknown provider", () => {
expect(getModelEnvVars("unknown/model")).toEqual([]);
});
Expand Down Expand Up @@ -80,6 +88,11 @@ describe("resolveModelSlug", () => {
expect(resolved).toBe("openai/gpt-5.5");
});

it("resolves qwen alias", () => {
const resolved = resolveModelSlug("qwen/qwen-coder");
expect(resolved).toBe("qwen/qwen3-coder-plus");
});

it("returns the raw resolve for deprecated aliases (does not walk fallback)", () => {
expect(resolveModelSlug("openai/gpt-codex")).toBe("openai/gpt-5.3-codex");
});
Expand Down
31 changes: 31 additions & 0 deletions models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface ModelAlias {
* resolution — for that use `fallback`. used for internal-only tier targets
* (e.g. gpt-5.4 as a subagent target without exposing it to users). */
hidden: boolean;
/** custom OpenAI-compatible BYOK provider with no models.dev entry — skips drift/CI-secret checks */
byok: boolean;
}

interface ModelDef {
Expand All @@ -71,6 +73,8 @@ interface ModelDef {
subagentModel?: string;
/** hide from selectable lists. does NOT affect resolution; for that use `fallback`. */
hidden?: boolean;
/** custom OpenAI-compatible BYOK provider with no models.dev entry; skips drift checks */
byok?: boolean;
}

export interface ProviderConfig {
Expand Down Expand Up @@ -273,6 +277,32 @@ export const providers = {
},
},
}),
qwen: provider({
displayName: "Qwen",
// precedence order; LLM_API_KEY is the generic OpenAI-compatible fallback (last)
envVars: ["QWEN_API_KEY", "DASHSCOPE_API_KEY", "LLM_API_KEY"],
models: {
"qwen-coder": {
displayName: "Qwen Coder",
resolve: "qwen/qwen3-coder-plus",
openRouterResolve: "openrouter/qwen/qwen3-coder-plus",
preferred: true,
byok: true,
},
"qwen-plus": {
displayName: "Qwen Plus",
resolve: "qwen/qwen-plus",
openRouterResolve: "openrouter/qwen/qwen-plus",
byok: true,
},
"qwen-max": {
displayName: "Qwen Max",
resolve: "qwen/qwen-max",
openRouterResolve: "openrouter/qwen/qwen3-max",
byok: true,
},
},
}),
opencode: provider({
displayName: "OpenCode",
envVars: ["OPENCODE_API_KEY"],
Expand Down Expand Up @@ -601,6 +631,7 @@ export const modelAliases: ModelAlias[] = Object.entries(providers).flatMap(
// directly without re-deriving the provider.
subagentModel: def.subagentModel ? `${providerKey}/${def.subagentModel}` : undefined,
hidden: def.hidden ?? false,
byok: def.byok ?? false,
}))
);

Expand Down
9 changes: 7 additions & 2 deletions test/ci.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,16 @@ const agnosticTests = getTestNamesFromDir("agnostic");
const adhocTests = getTestNamesFromDir("adhoc");

// all provider API key names + managed credentials (e.g. Codex auth blob)
// + GITHUB_TOKEN + model overrides
// + GITHUB_TOKEN + model overrides. byok-only providers (e.g. qwen) are excluded:
// Pullfrog holds no managed key for them, so there's nothing in the CI env blocks.
const isByokOnlyProvider = (p: (typeof providers)[keyof typeof providers]) =>
Object.values(p.models).every((m) => m.byok);
const expectedAgentEnvVars = [
"GITHUB_TOKEN",
...new Set(
Object.values(providers).flatMap((p) => [...p.envVars, ...(p.managedCredentials ?? [])])
Object.values(providers)
.filter((p) => !isByokOnlyProvider(p))
.flatMap((p) => [...p.envVars, ...(p.managedCredentials ?? [])])
),
"PULLFROG_MODEL",
].sort();
Expand Down
4 changes: 4 additions & 0 deletions test/models-catalog.main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ describe("models.dev validity", async () => {
// is validated separately by the Zen served-list test below.
if (alias.fallback) continue;

// byok providers (e.g. qwen) have no models.dev entry; nothing to validate
// upstream. their openRouterResolve, when set, is still checked below.
if (alias.byok) continue;

const parsed = parseResolve(alias.resolve);

it(`${alias.resolve} exists on models.dev`, () => {
Expand Down
13 changes: 13 additions & 0 deletions utils/apiKeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ describe("validateAgentApiKey — opencode", () => {
).toThrow("no API key found");
});

it("passes for qwen when a compatible key is set", () => {
process.env.QWEN_API_KEY = "qwen-test";
expect(() =>
validateAgentApiKey({
agent: opencode,
model: "qwen/qwen3-coder-plus",
authorized: new Set(),
owner,
name,
})
).not.toThrow();
});

it("passes the auto-select path when the authorized set is non-empty", () => {
expect(() =>
validateAgentApiKey({
Expand Down
2 changes: 2 additions & 0 deletions utils/apiKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ export function validateAgentApiKey(params: {
}

if (params.agent.name === "opencode") {
const envVars = getModelEnvVars(params.model);
if (envVars.includes("QWEN_API_KEY") && envVars.some((name) => hasEnvVar(name))) return;
if (params.authorized.has(params.model)) return;
throw new Error(
buildMissingApiKeyError({ owner: params.owner, name: params.name, model: params.model })
Expand Down