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
3 changes: 2 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,11 +748,12 @@ Examples:
provider
.command("set <name>")
.description("Set provider config on a profile")
.option("--preset <id>", "Apply a builtin preset (e.g. zai-glm, qwen-max, claude-work)")
.option("--base-url <url>", "API base URL (e.g. https://openrouter.ai/api/v1)")
.option("--model <model>", "Model identifier (e.g. anthropic/claude-3.5-sonnet)")
.option("--api-key-var <var>", "Env var name for the API key (default: OPENAI_API_KEY)")
.option("--display-name <name>", "Provider display name (e.g. OpenRouter, Ollama)")
.action(async (name: string, opts: { baseUrl?: string; model?: string; apiKeyVar?: string; displayName?: string }) => {
.action(async (name: string, opts: { preset?: string; baseUrl?: string; model?: string; apiKeyVar?: string; displayName?: string }) => {
const mod = await import("./commands/provider.js");
await mod.handleProviderSet(name, opts);
});
Expand Down
120 changes: 99 additions & 21 deletions packages/cli/src/commands/provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { loadConfig, saveConfig } from "../config.js";
import { success, error, info, cmd } from "../display.js";
import type { ProviderConfig } from "@axiom-labs/arc-core";
import { providerPresets, resolveProvider } from "@axiom-labs/arc-core";

// ─── Known provider presets ─────────────────────────────────────────

Expand Down Expand Up @@ -91,20 +92,71 @@ function getProfile(name?: string) {

// ─── Handlers ───────────────────────────────────────────────────────

export interface ProviderSetOpts {
baseUrl?: string;
model?: string;
apiKeyVar?: string;
displayName?: string;
/** Name of a builtin preset in `providerPresets` (e.g. "zai-glm", "claude-work"). */
preset?: string;
}

export async function handleProviderSet(
name: string,
opts: { baseUrl?: string; model?: string; apiKeyVar?: string; displayName?: string },
opts: ProviderSetOpts,
): Promise<void> {
const { config, profileName, profile } = getProfile(name);

if (!opts.baseUrl && !opts.model && !opts.apiKeyVar && !opts.displayName) {
error("Provide at least one option: --base-url, --model, --api-key-var, or --display-name");
info(`Example: ${cmd("arc provider set " + profileName + " --base-url https://openrouter.ai/api/v1 --model anthropic/claude-sonnet-4")}`);
const presetKey = opts.preset;
if (!presetKey && !opts.baseUrl && !opts.model && !opts.apiKeyVar && !opts.displayName) {
error("Provide at least one option: --preset, --base-url, --model, --api-key-var, or --display-name");
info(`Example: ${cmd("arc provider set " + profileName + " --preset zai-glm")}`);
info(` or: ${cmd("arc provider set " + profileName + " --base-url https://openrouter.ai/api/v1 --model anthropic/claude-sonnet-4")}`);
process.exit(1);
}

// Check if input matches a preset
const preset = PRESETS.find((p) =>
if (presetKey) {
const preset = providerPresets[presetKey];
if (!preset) {
const available = Object.keys(providerPresets).join(", ");
error(`Unknown preset "${presetKey}". Available: ${available}`);
info(`Run ${cmd("arc provider presets")} to see details.`);
process.exit(1);
}

// Preset overwrites existing provider fields; explicit flags still win over both.
const merged: ProviderConfig = {
...(profile.provider ?? {}),
...preset,
};
if (preset.env || profile.provider?.env) {
merged.env = { ...(preset.env ?? {}), ...(profile.provider?.env ?? {}) };
}
if (opts.baseUrl) merged.baseUrl = opts.baseUrl;
if (opts.model) merged.model = opts.model;
if (opts.apiKeyVar) merged.apiKeyEnvVar = opts.apiKeyVar;
if (opts.displayName) merged.displayName = opts.displayName;

profile.provider = merged;
saveConfig(config);

const label = merged.label ?? merged.displayName ?? presetKey;
success(`Provider for "${profileName}" configured from preset "${presetKey}": ${label}`);
if (merged.extends) info(` Extends: ${merged.extends}`);
if (merged.baseUrl) info(` Base URL: ${merged.baseUrl}`);
if (merged.model) info(` Model: ${merged.model}`);
if (merged.models?.length) info(` Models: ${merged.models.join(", ")}`);
if (merged.apiKeyEnvVar) info(` API key env var: ${merged.apiKeyEnvVar}`);
if (merged.env && Object.keys(merged.env).length > 0) {
for (const [k, v] of Object.entries(merged.env)) {
info(` env ${k}=${v}`);
}
}
return;
}

// OpenAI-compat path: match a legacy preset by baseUrl or displayName id.
const legacyPreset = PRESETS.find((p) =>
opts.baseUrl === p.baseUrl || opts.displayName?.toLowerCase() === p.id,
);

Expand All @@ -115,15 +167,13 @@ export async function handleProviderSet(
if (opts.apiKeyVar) existing.apiKeyEnvVar = opts.apiKeyVar;
if (opts.displayName) existing.displayName = opts.displayName;

// Apply preset defaults for fields not explicitly set
if (preset) {
if (!opts.apiKeyVar && !existing.apiKeyEnvVar) existing.apiKeyEnvVar = preset.apiKeyEnvVar;
if (!opts.displayName && !existing.displayName) existing.displayName = preset.displayName;
if (legacyPreset) {
if (!opts.apiKeyVar && !existing.apiKeyEnvVar) existing.apiKeyEnvVar = legacyPreset.apiKeyEnvVar;
if (!opts.displayName && !existing.displayName) existing.displayName = legacyPreset.displayName;
}

profile.provider = existing;

// Auto-set authType to openai-compat if not already
if (profile.authType !== "openai-compat" && profile.authType !== "api-key") {
profile.authType = "openai-compat";
}
Expand All @@ -136,28 +186,35 @@ export async function handleProviderSet(
if (existing.model) info(` Model: ${existing.model}`);
if (existing.apiKeyEnvVar) info(` API key env var: ${existing.apiKeyEnvVar}`);

// Hint about setting the key
const keyVar = existing.apiKeyEnvVar ?? "OPENAI_API_KEY";
info(`\nSet your API key: ${cmd(`arc set-key ${profileName}`)}`);
info(`Or via env: ${cmd(`export ${keyVar}=sk-...`)}`);
}

export async function handleProviderShow(name?: string): Promise<void> {
const { profileName, profile } = getProfile(name);
const p = profile.provider;
const resolved = resolveProvider(profile);

if (!p) {
if (!resolved) {
info(`No provider configured for "${profileName}".`);
info(`Set one with: ${cmd(`arc provider set ${profileName} --base-url <url>`)}`);
info(`Or use a preset: ${cmd("arc provider presets")}`);
return;
}

const label = p.displayName ?? "Custom Provider";
const label = resolved.label ?? resolved.displayName ?? "Custom Provider";
info(`Provider for "${profileName}": ${label}`);
console.log(` Base URL: ${p.baseUrl || "(not set)"}`);
console.log(` Model: ${p.model || "(not set)"}`);
console.log(` API key var: ${p.apiKeyEnvVar || "OPENAI_API_KEY"}`);
if (resolved.extends) console.log(` Extends: ${resolved.extends}`);
console.log(` Base URL: ${resolved.baseUrl || "(not set)"}`);
console.log(` Model: ${resolved.model || "(not set)"}`);
if (resolved.models?.length) console.log(` Models: ${resolved.models.join(", ")}`);
console.log(` API key var: ${resolved.apiKeyEnvVar || "OPENAI_API_KEY"}`);
if (resolved.env && Object.keys(resolved.env).length > 0) {
console.log(` Environment:`);
for (const [k, v] of Object.entries(resolved.env)) {
console.log(` ${k}=${v}`);
}
}
}

export async function handleProviderClear(name: string): Promise<void> {
Expand All @@ -174,7 +231,28 @@ export async function handleProviderClear(name: string): Promise<void> {
}

export async function handleProviderPresets(): Promise<void> {
info("Known provider presets:\n");
info("Adapter-extends presets (inherit a builtin adapter + env overrides):\n");

const idCol = Math.max("ID".length, ...Object.keys(providerPresets).map((k) => k.length));
console.log(
` ${"ID".padEnd(idCol)} ${"Extends".padEnd(9)} Label / Models`
);
console.log(
` ${"".padEnd(idCol, "-")} ${"".padEnd(9, "-")} --------------------------------`
);
for (const [id, preset] of Object.entries(providerPresets)) {
const label = preset.label ?? preset.displayName ?? "";
const models = preset.models?.length ? ` models: ${preset.models.join(", ")}` : "";
console.log(` ${id.padEnd(idCol)} ${(preset.extends ?? "-").padEnd(9)} ${label}${models}`);
if (preset.env) {
for (const [k, v] of Object.entries(preset.env)) {
console.log(` ${"".padEnd(idCol)} ${"".padEnd(9)} ${k}=${v}`);
}
}
}

console.log();
info("OpenAI-compatible presets (set baseUrl + model directly):\n");

for (const p of PRESETS) {
console.log(` ${p.displayName.padEnd(14)} ${p.baseUrl}`);
Expand All @@ -184,8 +262,8 @@ export async function handleProviderPresets(): Promise<void> {
console.log();
}

info("Quick setup example:");
console.log(` ${cmd("arc create openrouter --tool openai-compat --auth-type openai-compat")}`);
info("Quick setup examples:");
console.log(` ${cmd("arc provider set myprof --preset zai-glm")}`);
console.log(` ${cmd("arc provider set openrouter --base-url https://openrouter.ai/api/v1 --model anthropic/claude-sonnet-4")}`);
console.log(` ${cmd("arc set-key openrouter")}`);
console.log(` ${cmd("arc launch openrouter")}`);
Expand Down
59 changes: 58 additions & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import fs from "node:fs";
import path from "node:path";
import { getArcDir, getConfigPath, getProfileDir } from "./paths.js";
import { deepMerge } from "./shared-fs.js";
import type { ArcConfig, Profile } from "./types.js";
import { providerPresets } from "./provider-presets.js";
import type { ArcConfig, Profile, ProviderConfig } from "./types.js";

const AUTH_TYPES = new Set(["oauth", "api-key", "bedrock", "vertex", "foundry", "openai-compat"]);

Expand Down Expand Up @@ -209,6 +210,62 @@ export function resolveInstructions(profile: Profile): string | undefined {
return profile.instructions;
}

/**
* Built-in defaults applied when a provider extends a known adapter. These are
* the "base" fields a profile inherits before its own values override. For now
* the adapter-level defaults are minimal — the meaningful env/model data lives
* in {@link providerPresets}, which the CLI applies by copying the preset into
* the profile's provider config.
*/
const ADAPTER_DEFAULTS: Record<string, Partial<ProviderConfig>> = {
claude: {},
codex: {},
gemini: {},
opencode: {},
};

/**
* Resolve the effective provider config for a profile.
*
* Merge rules:
* 1. If `profile.provider` is undefined → returns `undefined`.
* 2. If `provider.extends` is unset → returns a fresh shallow copy (idempotent).
* 3. If `provider.extends` names a known source (an adapter or a preset id),
* the source fields are layered **under** the profile's own values. Scalar
* fields: profile wins on conflict. `env` is shallow-merged key-by-key
* (source first, profile last). `models` is replaced by the profile when
* explicitly set; otherwise taken from the source.
*
* The returned object is always a fresh shallow copy — the profile is not
* mutated.
*/
export function resolveProvider(profile: Profile): ProviderConfig | undefined {
const own = profile.provider;
if (!own) return undefined;
if (!own.extends) return { ...own };

// `extends` may reference a builtin adapter OR (via CLI copy-through) carry
// over to the preset-id lookup. Adapter defaults take precedence if both match.
const base: Partial<ProviderConfig> | undefined =
ADAPTER_DEFAULTS[own.extends] ?? providerPresets[own.extends];
if (!base) return { ...own };

// Scalar merge: base defaults, profile overrides win.
const merged: ProviderConfig = { ...base, ...own };

// env: shallow merge (base first, profile last).
if (base.env || own.env) {
merged.env = { ...(base.env ?? {}), ...(own.env ?? {}) };
}

// models: profile replaces base when set; otherwise inherit.
if (own.models === undefined && base.models) {
merged.models = [...base.models];
}

return merged;
}

export interface CloneProfileOptions {
/** When true (default), recursively copy the source configDir to the new profile directory. */
copyConfigDir?: boolean;
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,21 @@ export type {
AgentTool,
Profile,
ProviderConfig,
ProviderExtends,
ArcSettings,
ArcConfig,
SharedManifest,
EnforcementMode,
HookConfig,
} from "./types.js";

// Provider presets (Claude, Codex, Gemini, OpenCode — extends-style)
export {
providerPresets,
listProviderPresets,
getProviderPreset,
} from "./provider-presets.js";

export * from "./adapters/types.js";

// hooks: named exports to avoid collisions with adapters/types.ts placeholders
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/provider-presets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { ProviderConfig } from "./types.js";

/**
* Built-in provider presets.
*
* Each preset is a partial {@link ProviderConfig}. Presets that use `extends`
* inherit a builtin adapter's launch behavior (command, auth) and layer env
* overrides on top — this lets a profile target an Anthropic-compatible
* endpoint (Z.AI, Qwen, DeepSeek, local Ollama) or isolate a second credential
* set (claude-work vs. claude-personal) without re-implementing the adapter.
*
* Presets are merged into a profile's provider via `resolveProvider()` — the
* profile's own fields win over preset fields (shallow merge; `env` is also
* shallow-merged key-by-key).
*/
export const providerPresets: Record<string, Partial<ProviderConfig>> = {
"zai-glm": {
extends: "claude",
displayName: "Z.AI (GLM)",
label: "Z.AI GLM",
env: {
ANTHROPIC_BASE_URL: "https://api.z.ai/api/anthropic",
ANTHROPIC_AUTH_TOKEN_ENV: "ZAI_API_KEY",
},
models: ["glm-4.6"],
},
"qwen-max": {
extends: "claude",
displayName: "Qwen (DashScope)",
label: "Qwen Max",
env: {
ANTHROPIC_BASE_URL: "https://dashscope-intl.aliyuncs.com/api/v2/apps/anthropic",
ANTHROPIC_AUTH_TOKEN_ENV: "DASHSCOPE_API_KEY",
},
models: ["qwen-max", "qwen-plus"],
},
"deepseek-chat": {
extends: "claude",
displayName: "DeepSeek (Anthropic)",
label: "DeepSeek",
env: {
ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic",
ANTHROPIC_AUTH_TOKEN_ENV: "DEEPSEEK_API_KEY",
},
models: ["deepseek-chat", "deepseek-reasoner"],
},
"ollama-claude-local": {
extends: "claude",
displayName: "Ollama (local Anthropic)",
label: "Ollama local",
env: {
ANTHROPIC_BASE_URL: "http://127.0.0.1:11434/anthropic",
},
models: ["llama3.1", "qwen2.5-coder"],
},
"claude-work": {
extends: "claude",
displayName: "Claude (work)",
label: "Claude (work)",
env: {
CLAUDE_CONFIG_DIR: "~/.arc/profiles/claude-work/config",
},
},
"claude-personal": {
extends: "claude",
displayName: "Claude (personal)",
label: "Claude (personal)",
env: {
CLAUDE_CONFIG_DIR: "~/.arc/profiles/claude-personal/config",
},
},
};

/** Return the list of preset ids in stable, declaration order. */
export function listProviderPresets(): string[] {
return Object.keys(providerPresets);
}

/** Fetch a preset by id, or `undefined` if none matches. */
export function getProviderPreset(id: string): Partial<ProviderConfig> | undefined {
return providerPresets[id];
}
Loading
Loading