Skip to content
Merged
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
44 changes: 41 additions & 3 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -939,6 +943,8 @@ function AppInner({
}, []);

const loopRef = useRef<CacheFirstLoop | null>(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
Expand All @@ -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.
Expand All @@ -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-
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/context-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
53 changes: 53 additions & 0 deletions src/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions tests/loop-client-reload.test.ts
Original file line number Diff line number Diff line change
@@ -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" }]);
});
});
83 changes: 83 additions & 0 deletions tests/runtime-config.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading