Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c0d39f7
Add provider health and per-provider controls
huxcrux Mar 24, 2026
8151dbd
Adjust provider login timeout and remove account helper leftovers
huxcrux Mar 24, 2026
7a3d8ed
Surface provider plans and share Codex account helpers
huxcrux Mar 24, 2026
06c4824
Fix provider health review findings
huxcrux Mar 24, 2026
232c918
Fix provider auth probe cleanup
huxcrux Mar 24, 2026
e89c91b
Fix test environment assumptions
huxcrux Mar 24, 2026
9e534e1
Fix wsServer stacked action test fixture
huxcrux Mar 24, 2026
929716a
Fix codex app-server probe spawner usage
huxcrux Mar 24, 2026
58f3692
Address remaining review follow-ups
huxcrux Mar 24, 2026
5bc8f46
Use schema-based codex app-server JSON handling
huxcrux Mar 24, 2026
b10393f
Use schema-based auth probe JSON decoding
huxcrux Mar 24, 2026
5e7bf4a
Align provider gating with review feedback
huxcrux Mar 24, 2026
8ffd6a4
Restore auth probe parse-attempt semantics
huxcrux Mar 24, 2026
d121378
Fix provider settings review follow-ups
huxcrux Mar 24, 2026
6f8d165
Remove unused provider options helper
huxcrux Mar 25, 2026
afe261e
Use node for fake codex test shim
huxcrux Mar 25, 2026
789bb95
Use provider overrides in health checks
huxcrux Mar 25, 2026
2273bd4
fix open PR review comments
huxcrux Mar 25, 2026
64a6f91
fix review feedback on provider health
huxcrux Mar 25, 2026
b477b58
Fix remaining provider review feedback
huxcrux Mar 25, 2026
e29b71b
Fix settings provider model availability
huxcrux Mar 25, 2026
3170252
Harden provider account and health probes
huxcrux Mar 25, 2026
346bd1d
Fix remaining provider review follow-ups
huxcrux Mar 25, 2026
4b6c373
Handle string app-server account errors
huxcrux Mar 25, 2026
e05d87a
Fix remaining review comments
huxcrux Mar 25, 2026
5160c5e
Fix spark fallback for unknown Codex plans
huxcrux Mar 25, 2026
ffb8fe5
Merge branch 'main' into feat/provider-settings-cleanup
huxcrux Mar 25, 2026
d52b1b1
Clarify provider CLI detection source
huxcrux Mar 25, 2026
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
34 changes: 30 additions & 4 deletions apps/server/src/codexAppServerManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ import path from "node:path";
import { ApprovalRequestId, ThreadId } from "@t3tools/contracts";

import {
buildCodexInitializeParams,
CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
CodexAppServerManager,
classifyCodexStderrLine,
isRecoverableThreadResumeError,
normalizeCodexModelSlug,
} from "./codexAppServerManager";
import {
buildCodexInitializeParams,
readCodexAccountSnapshot,
resolveCodexModelForAccount,
} from "./codexAppServerManager";
} from "./provider/codexAccount";

const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);

Expand Down Expand Up @@ -250,7 +252,6 @@ describe("readCodexAccountSnapshot", () => {
expect(
readCodexAccountSnapshot({
type: "chatgpt",
email: "[email protected]",
planType: "plus",
}),
).toEqual({
Expand All @@ -264,7 +265,6 @@ describe("readCodexAccountSnapshot", () => {
expect(
readCodexAccountSnapshot({
type: "chatgpt",
email: "[email protected]",
planType: "pro",
}),
).toEqual({
Expand All @@ -285,6 +285,32 @@ describe("readCodexAccountSnapshot", () => {
sparkEnabled: true,
});
});

it("accepts snake_case plan fields from account/read payloads", () => {
expect(
readCodexAccountSnapshot({
type: "chatgpt",
plan_type: "team",
}),
).toEqual({
type: "chatgpt",
planType: "team",
sparkEnabled: true,
});
});

it("falls back to an unknown plan type for unexpected chatgpt plan labels", () => {
expect(
readCodexAccountSnapshot({
type: "chatgpt",
planType: "mystery-tier",
}),
).toEqual({
type: "chatgpt",
planType: "unknown",
sparkEnabled: true,
});
});
});

describe("resolveCodexModelForAccount", () => {
Expand Down
115 changes: 24 additions & 91 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ import {
isCodexCliVersionSupported,
parseCodexCliVersion,
} from "./provider/codexCliVersion";
import {
buildCodexInitializeParams,
readCodexAccountSnapshot,
resolveCodexModelForAccount,
type CodexAccountSnapshot,
} from "./provider/codexAccount";

type PendingRequestKey = string;

Expand Down Expand Up @@ -97,23 +103,6 @@ interface JsonRpcNotification {
params?: unknown;
}

type CodexPlanType =
| "free"
| "go"
| "plus"
| "pro"
| "team"
| "business"
| "enterprise"
| "edu"
| "unknown";

interface CodexAccountSnapshot {
readonly type: "apiKey" | "chatgpt" | "unknown";
readonly planType: CodexPlanType | null;
readonly sparkEnabled: boolean;
}

export interface CodexAppServerSendTurnInput {
readonly threadId: ThreadId;
readonly input?: string;
Expand Down Expand Up @@ -162,50 +151,6 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
"unknown thread",
"does not exist",
];
const CODEX_DEFAULT_MODEL = "gpt-5.3-codex";
const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark";
const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set<CodexPlanType>(["free", "go", "plus"]);

function asObject(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
return value as Record<string, unknown>;
}

function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot {
const record = asObject(response);
const account = asObject(record?.account) ?? record;
const accountType = asString(account?.type);

if (accountType === "apiKey") {
return {
type: "apiKey",
planType: null,
sparkEnabled: true,
};
}

if (accountType === "chatgpt") {
const planType = (account?.planType as CodexPlanType | null) ?? "unknown";
return {
type: "chatgpt",
planType,
sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType),
};
}

return {
type: "unknown",
planType: null,
sparkEnabled: true,
};
}

export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)

You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions.
Expand Down Expand Up @@ -358,17 +303,6 @@ function mapCodexRuntimeMode(runtimeMode: RuntimeMode): {
};
}

export function resolveCodexModelForAccount(
model: string | undefined,
account: CodexAccountSnapshot,
): string | undefined {
if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) {
return model;
}

return CODEX_DEFAULT_MODEL;
}

/**
* On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe`
* wrapper, leaving the actual command running. Use `taskkill /T` to kill the
Expand Down Expand Up @@ -402,19 +336,6 @@ export function normalizeCodexModelSlug(
return normalized;
}

export function buildCodexInitializeParams() {
return {
clientInfo: {
name: "t3code_desktop",
title: "T3 Code Desktop",
version: "0.1.0",
},
capabilities: {
experimentalApi: true,
},
} as const;
}

function buildCodexCollaborationMode(input: {
readonly interactionMode?: "default" | "plan";
readonly model?: string;
Expand Down Expand Up @@ -587,21 +508,33 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
this.writeMessage(context, { method: "initialized" });
try {
const modelListResponse = await this.sendRequest(context, "model/list", {});
console.log("codex model/list response", modelListResponse);
const modelCount =
this.readArray(modelListResponse, "models")?.length ??
this.readArray(modelListResponse)?.length;
await Effect.logDebug("codex model/list completed", {
threadId,
...(modelCount !== undefined ? { modelCount } : {}),
}).pipe(this.runPromise);
} catch (error) {
console.log("codex model/list failed", error);
await Effect.logDebug("codex model/list failed", {
threadId,
cause: error instanceof Error ? error.message : String(error),
}).pipe(this.runPromise);
}
try {
const accountReadResponse = await this.sendRequest(context, "account/read", {});
console.log("codex account/read response", accountReadResponse);
context.account = readCodexAccountSnapshot(accountReadResponse);
console.log("codex subscription status", {
await Effect.logDebug("codex account/read completed", {
threadId,
type: context.account.type,
planType: context.account.planType,
sparkEnabled: context.account.sparkEnabled,
});
}).pipe(this.runPromise);
} catch (error) {
console.log("codex account/read failed", error);
await Effect.logDebug("codex account/read failed", {
threadId,
cause: error instanceof Error ? error.message : String(error),
}).pipe(this.runPromise);
}

const normalizedModel = resolveCodexModelForAccount(
Expand Down
43 changes: 24 additions & 19 deletions apps/server/src/persistence/NodeSqliteClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,34 @@ import { assert, it } from "@effect/vitest";
import { Effect } from "effect";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import * as SqliteClient from "./NodeSqliteClient.ts";
const supportsNodeSqlite = typeof process.versions.bun !== "string";

const layer = it.layer(SqliteClient.layerMemory());
if (!supportsNodeSqlite) {
it.skip("NodeSqliteClient requires node:sqlite support", () => {});
} else {
const SqliteClient = await import("./NodeSqliteClient.ts");
const layer = it.layer(SqliteClient.layerMemory());

layer("NodeSqliteClient", (it) => {
it.effect("runs prepared queries and returns positional values", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
layer("NodeSqliteClient", (it) => {
it.effect("runs prepared queries and returns positional values", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`;
yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`;
yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`;
yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`;

const rows = yield* sql<{ readonly id: number; readonly name: string }>`
const rows = yield* sql<{ readonly id: number; readonly name: string }>`
SELECT id, name FROM entries ORDER BY id
`;
assert.equal(rows.length, 2);
assert.equal(rows[0]?.name, "alpha");
assert.equal(rows[1]?.name, "beta");
assert.equal(rows.length, 2);
assert.equal(rows[0]?.name, "alpha");
assert.equal(rows[1]?.name, "beta");

const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values;
assert.equal(values.length, 2);
assert.equal(values[0]?.[1], "alpha");
assert.equal(values[1]?.[1], "beta");
}),
);
});
const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values;
assert.equal(values.length, 2);
assert.equal(values[0]?.[1], "alpha");
assert.equal(values[1]?.[1], "beta");
}),
);
});
}
Loading