diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 363d515..91b1372 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -70,6 +70,10 @@ import { } from "../../memory/session.js"; import type { QQChannel } from "../../qq/channel.js"; import { useQQChannel } from "../../qq/use-qq-channel.js"; +import { + RuntimeConnectionConfigSource, + sameRuntimeConnectionConfig, +} from "../../runtime-config.js"; import type { ActiveModal, DashboardEvent, @@ -939,6 +943,8 @@ function AppInner({ }, []); const loopRef = useRef(null); + const runtimeConfigSource = useMemo(() => new RuntimeConnectionConfigSource(), []); + const initialRuntimeConfigRef = useRef(runtimeConfigSource.read()); // hookList + currentRootDir intentionally NOT in deps —they seed // the loop on first construction (loopRef guards a single // instantiation), and later edits flow in through the mutable @@ -949,13 +955,17 @@ function AppInner({ // biome-ignore lint/correctness/useExhaustiveDependencies: currentRootDir —see comment above const loop = useMemo(() => { if (loopRef.current) return loopRef.current; - const client = new DeepSeekClient({ baseUrl: loadBaseUrl() }); + const initialRuntimeConfig = initialRuntimeConfigRef.current; + const client = new DeepSeekClient({ + apiKey: initialRuntimeConfig?.apiKey, + baseUrl: initialRuntimeConfig?.baseUrl ?? loadBaseUrl(), + }); // Register run_skill HERE (not in code.tsx / chat.tsx) because // subagent-runAs skills need the client + parent registry to // spawn child loops. Wiring lives in App.tsx so the same code // path covers both code mode and chat mode. // - // The closure captures `tools` (parent registry), `client`, and + // The closure captures `tools` (parent registry), the loop ref, and // the subagent sink ref by lexical scope —`spawnSubagent` reads // them per invocation, so a sink handler attached after this // registration still receives events. @@ -964,7 +974,7 @@ function AppInner({ projectRoot: codeMode?.rootDir, subagentRunner: async (skill, task, signal) => { const result = await spawnSubagent({ - client, + client: loopRef.current?.client ?? client, parentRegistry: tools, parentSignal: signal, // Skill body is the subagent's persona/playbook; the user- @@ -1272,6 +1282,34 @@ function AppInner({ const { balance, models, latestVersion, refreshBalance, refreshModels, refreshLatestVersion } = useSessionInfo(loop); + useEffect(() => { + let applied = initialRuntimeConfigRef.current ?? { + apiKey: loop.client.apiKey, + baseUrl: loop.client.baseUrl, + }; + const timer = setInterval(() => { + const next = runtimeConfigSource.read(); + if (!next || sameRuntimeConnectionConfig(applied, next)) return; + if (busyRef.current || loop.inflight.size > 0) return; + if (!next.apiKey) { + log.pushWarning("config reload skipped", "DeepSeek API key is empty."); + applied = next; + return; + } + try { + loop.replaceClient(new DeepSeekClient({ apiKey: next.apiKey, baseUrl: next.baseUrl })); + applied = next; + refreshBalance(); + refreshModels(); + log.pushInfo("config: DeepSeek connection reloaded"); + } catch (err) { + log.pushWarning("config reload failed", (err as Error).message); + } + }, 1000); + timer.unref(); + return () => clearInterval(timer); + }, [log, loop, refreshBalance, refreshModels, runtimeConfigSource]); + // Keep the dashboard-server ref-mirrors in sync with their state. // These four are the load-bearing live reads for the attached // dashboard's read APIs; without these mirrors the captured diff --git a/src/context-manager.ts b/src/context-manager.ts index 0d1a4f1..87975dc 100644 --- a/src/context-manager.ts +++ b/src/context-manager.ts @@ -103,6 +103,10 @@ function extractPinnedSkills(head: ChatMessage[]): { export class ContextManager { constructor(private deps: ContextManagerDeps) {} + replaceClient(client: DeepSeekClient): void { + this.deps.client = client; + } + /** Decision after a turn's response — fold, exit with summary, or carry on. */ decideAfterUsage( usage: Usage | null, diff --git a/src/loop.ts b/src/loop.ts index 91380f4..4de1c1c 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -109,7 +109,7 @@ export interface ReconfigurableOptions { } export class CacheFirstLoop { - readonly client: DeepSeekClient; + private _client: DeepSeekClient; readonly prefix: ImmutablePrefix; readonly tools: ToolRegistry; readonly log = new AppendOnlyLog(); @@ -179,8 +179,12 @@ export class CacheFirstLoop { return this._turn; } + get client(): DeepSeekClient { + return this._client; + } + constructor(opts: CacheFirstLoopOptions) { - this.client = opts.client; + this._client = opts.client; this.prefix = opts.prefix; this.tools = opts.tools ?? new ToolRegistry(); this.model = opts.model ?? "deepseek-v4-flash"; @@ -366,6 +370,12 @@ export class CacheFirstLoop { if (opts.autoEscalate !== undefined) this.autoEscalate = opts.autoEscalate; } + /** Swap provider credentials/endpoint without rebuilding session state. */ + replaceClient(client: DeepSeekClient): void { + this._client = client; + this.context.replaceClient(client); + } + /** `null` disables the cap; any change re-arms the 80% warning. */ setBudget(usd: number | null): void { this.budgetUsd = typeof usd === "number" && usd > 0 ? usd : null; diff --git a/src/runtime-config.ts b/src/runtime-config.ts new file mode 100644 index 0000000..9813ffd --- /dev/null +++ b/src/runtime-config.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs"; +import { type ReasonixConfig, defaultConfigPath } from "./config.js"; + +export interface RuntimeConnectionConfig { + apiKey?: string; + baseUrl?: string; +} + +function readConfigStrict(path: string): ReasonixConfig | null { + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + return parsed && typeof parsed === "object" ? (parsed as ReasonixConfig) : null; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + return null; + } +} + +/** Re-read connection settings while preserving genuine environment overrides. + * Matching env/file values are the CLI's config bridge; distinct env values stay pinned. */ +export class RuntimeConnectionConfigSource { + private readonly apiKeyPinnedByEnv: boolean; + private readonly baseUrlPinnedByEnv: boolean; + + constructor( + private readonly path = defaultConfigPath(), + private readonly env: NodeJS.ProcessEnv = process.env, + ) { + const initial = readConfigStrict(path) ?? {}; + this.apiKeyPinnedByEnv = Boolean( + env.DEEPSEEK_API_KEY && env.DEEPSEEK_API_KEY !== initial.apiKey, + ); + this.baseUrlPinnedByEnv = Boolean( + env.DEEPSEEK_BASE_URL && env.DEEPSEEK_BASE_URL !== initial.baseUrl, + ); + } + + read(): RuntimeConnectionConfig | null { + const config = readConfigStrict(this.path); + if (!config) return null; + return { + apiKey: this.apiKeyPinnedByEnv ? this.env.DEEPSEEK_API_KEY : config.apiKey, + baseUrl: this.baseUrlPinnedByEnv ? this.env.DEEPSEEK_BASE_URL : config.baseUrl, + }; + } +} + +export function sameRuntimeConnectionConfig( + left: RuntimeConnectionConfig, + right: RuntimeConnectionConfig, +): boolean { + return left.apiKey === right.apiKey && left.baseUrl === right.baseUrl; +} diff --git a/tests/loop-client-reload.test.ts b/tests/loop-client-reload.test.ts new file mode 100644 index 0000000..d4cfae1 --- /dev/null +++ b/tests/loop-client-reload.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { DeepSeekClient } from "../src/client.js"; +import { CacheFirstLoop } from "../src/loop.js"; +import { ImmutablePrefix } from "../src/memory/runtime.js"; + +describe("CacheFirstLoop client reload", () => { + it("replaces the provider client without rebuilding loop state", () => { + const initial = new DeepSeekClient({ apiKey: "sk-initial" }); + const replacement = new DeepSeekClient({ + apiKey: "sk-replacement", + baseUrl: "https://provider.example.com/", + }); + const loop = new CacheFirstLoop({ + client: initial, + prefix: new ImmutablePrefix({ system: "test" }), + session: null, + }); + loop.appendAndPersist({ role: "user", content: "keep me" }); + + loop.replaceClient(replacement); + + expect(loop.client).toBe(replacement); + expect(loop.client.baseUrl).toBe("https://provider.example.com"); + expect(loop.log.entries).toEqual([{ role: "user", content: "keep me" }]); + }); +}); diff --git a/tests/runtime-config.test.ts b/tests/runtime-config.test.ts new file mode 100644 index 0000000..9d7c47d --- /dev/null +++ b/tests/runtime-config.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { writeConfig } from "../src/config.js"; +import { + RuntimeConnectionConfigSource, + sameRuntimeConnectionConfig, +} from "../src/runtime-config.js"; + +describe("runtime connection config", () => { + const dirs: string[] = []; + + function configPath(): string { + const dir = mkdtempSync(join(tmpdir(), "carboncode-runtime-config-")); + dirs.push(dir); + return join(dir, "config.json"); + } + + afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + it("re-reads API key and base URL changes from the config file", () => { + const path = configPath(); + writeConfig({ apiKey: "sk-old", baseUrl: "https://old.example.com" }, path); + const source = new RuntimeConnectionConfigSource(path, {}); + + expect(source.read()).toEqual({ + apiKey: "sk-old", + baseUrl: "https://old.example.com", + }); + + writeConfig({ apiKey: "sk-new", baseUrl: "https://new.example.com" }, path); + expect(source.read()).toEqual({ + apiKey: "sk-new", + baseUrl: "https://new.example.com", + }); + }); + + it("allows reload when process.env only mirrors the initial config", () => { + const path = configPath(); + writeConfig({ apiKey: "sk-old" }, path); + const source = new RuntimeConnectionConfigSource(path, { + DEEPSEEK_API_KEY: "sk-old", + }); + + writeConfig({ apiKey: "sk-new" }, path); + expect(source.read()?.apiKey).toBe("sk-new"); + }); + + it("keeps explicit environment overrides pinned", () => { + const path = configPath(); + writeConfig({ apiKey: "sk-file", baseUrl: "https://file.example.com" }, path); + const source = new RuntimeConnectionConfigSource(path, { + DEEPSEEK_API_KEY: "sk-env", + DEEPSEEK_BASE_URL: "https://env.example.com", + }); + + writeConfig({ apiKey: "sk-new", baseUrl: "https://new.example.com" }, path); + expect(source.read()).toEqual({ + apiKey: "sk-env", + baseUrl: "https://env.example.com", + }); + }); + + it("ignores a partially written config until valid JSON is available", () => { + const path = configPath(); + writeConfig({ apiKey: "sk-old" }, path); + const source = new RuntimeConnectionConfigSource(path, {}); + + writeFileSync(path, '{"apiKey":', "utf8"); + expect(source.read()).toBeNull(); + + writeConfig({ apiKey: "sk-new" }, path); + expect(source.read()?.apiKey).toBe("sk-new"); + }); + + it("compares connection snapshots", () => { + expect(sameRuntimeConnectionConfig({ apiKey: "a" }, { apiKey: "a" })).toBe(true); + expect(sameRuntimeConnectionConfig({ apiKey: "a" }, { apiKey: "b" })).toBe(false); + }); +});