mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Stop iterating on individual 400 errors and implement the full Codex Responses-API protocol once, from the source. Wrote a 713-line reference at docs/codex-protocol-spec.md from the official openai/codex Rust client; this commit is the TS implementation against that spec. Required pieces that were missing in v1.4.11: - `ChatGPT-Account-ID` header — extracted from the `chatgpt_account_id` claim of the OAuth id_token (also accepts the URI-namespaced variant under `https://api.openai.com/auth`). Without it: 401. - `originator`, `User-Agent`, `Accept: text/event-stream`, `session_id`/`session-id` (both spellings), `thread_id`/`thread-id`. - Body fields `reasoning: null` and `include: []` are required even when no reasoning is requested. - Model slug is `gpt-5-codex`, not `gpt-5.3-codex` (the latter is a test placeholder upstream). - Refresh endpoint takes a JSON body, not form-urlencoded. Storage codec rewrite: `codexAccessTokenEncrypted` now holds an encrypted JSON blob `{ accessToken, accountId, expiresAt }` instead of just the raw access token, so the account id stays available across requests. v1.4.7-v1.4.11 records that lack the field cannot be revived — `decryptCodexCreds` returns null for those rows and `resolveCodexProvider` flips the connection to `expired`, prompting the user to re-link once. Removed the dead browser-flow routes (`/api/auth/codex/authorize` and `/callback`). They never worked on hosted domains because Hydra's redirect-URI allow-list for the public Codex client ID covers only localhost; device-code is the only viable path. CodexClient SSE parsing extended to handle `response.failed` and `response.incomplete` terminal events, and to read the actual routed model from the `OpenAI-Model` response header. Tests rebuilt: codex-oauth covers the full device-code flow with a synthesised id_token JWT, the JSON-body refresh, and the storage-codec round-trip. CodexClient tests carry an `accountId` through the constructor. All 720 tests pass; typecheck clean. Refs: docs/codex-protocol-spec.md §1-§6 for every claim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
user: { findUnique: vi.fn(), update: vi.fn() },
|
|
appSettings: { findUnique: vi.fn() },
|
|
},
|
|
}));
|
|
vi.mock("@/lib/crypto", () => ({
|
|
decrypt: vi.fn((v: string) => `decrypted:${v}`),
|
|
encrypt: vi.fn((v: string) => `encrypted:${v}`),
|
|
}));
|
|
vi.mock("@/lib/ai/codex-oauth", () => ({
|
|
refreshDeviceTokens: vi.fn(),
|
|
encryptCodexCreds: vi.fn(
|
|
(c: { accessToken: string; refreshToken: string; accountId: string }) => ({
|
|
accessEncrypted: `enc:${c.accessToken}|${c.accountId}`,
|
|
refreshEncrypted: `enc:${c.refreshToken}`,
|
|
}),
|
|
),
|
|
decryptCodexCreds: vi.fn(),
|
|
}));
|
|
|
|
import { resolveProvider } from "../provider";
|
|
import { prisma } from "@/lib/db";
|
|
import { decrypt, encrypt } from "@/lib/crypto";
|
|
import { decryptCodexCreds, encryptCodexCreds } from "@/lib/ai/codex-oauth";
|
|
|
|
/**
|
|
* resolveProvider() makes up to TWO findUnique calls per invocation:
|
|
* 1. Read user-level AI config (aiProvider/...).
|
|
* 2. If falling back to codex, read codex token fields.
|
|
* We use mockResolvedValueOnce in order so each call returns the right shape.
|
|
*/
|
|
|
|
describe("resolveProvider", () => {
|
|
beforeEach(() => {
|
|
// Reset mocks AND mockResolvedValueOnce queues, then re-establish
|
|
// the deterministic factory implementations for crypto/oauth helpers.
|
|
vi.resetAllMocks();
|
|
vi.mocked(decrypt).mockImplementation((v: string) => `decrypted:${v}`);
|
|
vi.mocked(encrypt).mockImplementation((v: string) => `encrypted:${v}`);
|
|
vi.mocked(encryptCodexCreds).mockImplementation(
|
|
(c: {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
accountId: string;
|
|
}) => ({
|
|
accessEncrypted: `enc:${c.accessToken}|${c.accountId}`,
|
|
refreshEncrypted: `enc:${c.refreshToken}`,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns codex provider when user has valid tokens (no explicit choice)", async () => {
|
|
vi.mocked(decryptCodexCreds).mockReturnValue({
|
|
accessToken: "oauth-access",
|
|
refreshToken: "decrypted-refresh",
|
|
accountId: "acct-123",
|
|
expiresAt: new Date(Date.now() + 3600_000),
|
|
});
|
|
|
|
vi.mocked(prisma.user.findUnique)
|
|
// 1st call: user AI config — none selected
|
|
.mockResolvedValueOnce({
|
|
aiProvider: null,
|
|
aiModel: null,
|
|
aiBaseUrl: null,
|
|
aiAnthropicKeyEncrypted: null,
|
|
aiLocalKeyEncrypted: null,
|
|
} as never)
|
|
// 2nd call: codex tokens
|
|
.mockResolvedValueOnce({
|
|
codexAccessTokenEncrypted: "enc-access",
|
|
codexRefreshTokenEncrypted: "enc-refresh",
|
|
codexTokenExpiresAt: new Date(Date.now() + 3600000),
|
|
codexConnectionStatus: "connected",
|
|
} as never);
|
|
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("codex");
|
|
});
|
|
|
|
it("returns admin-key when user has no codex but admin key exists", async () => {
|
|
vi.mocked(prisma.user.findUnique)
|
|
.mockResolvedValueOnce({
|
|
aiProvider: null,
|
|
aiModel: null,
|
|
aiBaseUrl: null,
|
|
aiAnthropicKeyEncrypted: null,
|
|
aiLocalKeyEncrypted: null,
|
|
} as never)
|
|
.mockResolvedValueOnce({
|
|
codexAccessTokenEncrypted: null,
|
|
codexRefreshTokenEncrypted: null,
|
|
codexTokenExpiresAt: null,
|
|
codexConnectionStatus: "disconnected",
|
|
} as never);
|
|
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
|
adminAiKeyEncrypted: "enc-admin-key",
|
|
adminAiModel: "gpt-4o-mini",
|
|
adminAiBaseUrl: "https://api.openai.com/v1",
|
|
} as never);
|
|
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("admin-key");
|
|
});
|
|
|
|
it("returns none when nothing is configured", async () => {
|
|
vi.mocked(prisma.user.findUnique)
|
|
.mockResolvedValueOnce({
|
|
aiProvider: null,
|
|
aiModel: null,
|
|
aiBaseUrl: null,
|
|
aiAnthropicKeyEncrypted: null,
|
|
aiLocalKeyEncrypted: null,
|
|
} as never)
|
|
.mockResolvedValueOnce({
|
|
codexAccessTokenEncrypted: null,
|
|
codexRefreshTokenEncrypted: null,
|
|
codexTokenExpiresAt: null,
|
|
codexConnectionStatus: "disconnected",
|
|
} as never);
|
|
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
|
adminAiKeyEncrypted: null,
|
|
} as never);
|
|
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("none");
|
|
});
|
|
|
|
it("falls back to admin-key when codex status is expired", async () => {
|
|
vi.mocked(prisma.user.findUnique)
|
|
.mockResolvedValueOnce({
|
|
aiProvider: null,
|
|
aiModel: null,
|
|
aiBaseUrl: null,
|
|
aiAnthropicKeyEncrypted: null,
|
|
aiLocalKeyEncrypted: null,
|
|
} as never)
|
|
.mockResolvedValueOnce({
|
|
codexAccessTokenEncrypted: "enc-access",
|
|
codexRefreshTokenEncrypted: "enc-refresh",
|
|
codexTokenExpiresAt: new Date(Date.now() - 1000),
|
|
codexConnectionStatus: "expired",
|
|
} as never);
|
|
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
|
adminAiKeyEncrypted: "enc-admin",
|
|
adminAiModel: "gpt-4o",
|
|
adminAiBaseUrl: "https://api.openai.com/v1",
|
|
} as never);
|
|
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("admin-key");
|
|
});
|
|
|
|
it("returns Anthropic provider when user picks ANTHROPIC and has key", async () => {
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
|
aiProvider: "ANTHROPIC",
|
|
aiModel: "claude-3-5-sonnet-latest",
|
|
aiBaseUrl: null,
|
|
aiAnthropicKeyEncrypted: "enc-anthropic-key",
|
|
aiLocalKeyEncrypted: null,
|
|
} as never);
|
|
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("anthropic");
|
|
// Codex/admin lookups must NOT happen when user provider resolved.
|
|
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back when user picks ANTHROPIC but has no key", async () => {
|
|
vi.mocked(prisma.user.findUnique)
|
|
.mockResolvedValueOnce({
|
|
aiProvider: "ANTHROPIC",
|
|
aiModel: "claude-3-5-sonnet-latest",
|
|
aiBaseUrl: null,
|
|
aiAnthropicKeyEncrypted: null,
|
|
aiLocalKeyEncrypted: null,
|
|
} as never)
|
|
.mockResolvedValueOnce({
|
|
codexAccessTokenEncrypted: null,
|
|
codexConnectionStatus: "disconnected",
|
|
} as never);
|
|
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
|
adminAiKeyEncrypted: "enc-admin",
|
|
adminAiModel: "gpt-4o-mini",
|
|
adminAiBaseUrl: "https://api.openai.com/v1",
|
|
} as never);
|
|
|
|
// ANTHROPIC explicit choice ≠ CHATGPT_OAUTH/null → codex branch is skipped.
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("admin-key");
|
|
});
|
|
|
|
it("returns Local provider when user picks LOCAL with baseUrl", async () => {
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
|
aiProvider: "LOCAL",
|
|
aiModel: "llama3:8b",
|
|
aiBaseUrl: "http://localhost:11434/v1",
|
|
aiAnthropicKeyEncrypted: null,
|
|
aiLocalKeyEncrypted: null,
|
|
} as never);
|
|
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("local");
|
|
});
|
|
|
|
it("OPENAI selection routes through admin OpenAI key", async () => {
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
|
aiProvider: "OPENAI",
|
|
aiModel: null,
|
|
aiBaseUrl: null,
|
|
aiAnthropicKeyEncrypted: null,
|
|
aiLocalKeyEncrypted: null,
|
|
} as never);
|
|
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
|
adminAiKeyEncrypted: "enc-admin",
|
|
adminAiModel: "gpt-4o-mini",
|
|
adminAiBaseUrl: "https://api.openai.com/v1",
|
|
} as never);
|
|
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("admin-key");
|
|
});
|
|
|
|
it("OPENAI with user key ignores stored aiBaseUrl (provider-leak guard)", async () => {
|
|
// Regression for v1.4.6 T4: a user who had configured LOCAL with a
|
|
// private LAN URL and later switched to OPENAI must NOT have their
|
|
// OpenAI key forwarded to that URL. The persisted `aiBaseUrl` is
|
|
// shared across providers, so `buildUserProvider` hardcodes
|
|
// api.openai.com for OPENAI regardless of what the column carries.
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
|
aiProvider: "OPENAI",
|
|
aiModel: null,
|
|
aiBaseUrl: "http://192.168.0.42/v1", // stale LOCAL value
|
|
aiAnthropicKeyEncrypted: null,
|
|
aiLocalKeyEncrypted: null,
|
|
aiOpenaiKeyEncrypted: "enc-user-openai-key",
|
|
} as never);
|
|
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("admin-key");
|
|
const inspect = provider as unknown as {
|
|
config: { baseUrl: string };
|
|
};
|
|
expect(inspect.config.baseUrl).toBe("https://api.openai.com/v1");
|
|
});
|
|
|
|
it("ANTHROPIC ignores stored aiBaseUrl (provider-leak guard)", async () => {
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
|
aiProvider: "ANTHROPIC",
|
|
aiModel: "claude-3-5-sonnet-latest",
|
|
aiBaseUrl: "http://192.168.0.42/v1", // stale LOCAL value
|
|
aiAnthropicKeyEncrypted: "enc-user-anthropic-key",
|
|
aiLocalKeyEncrypted: null,
|
|
aiOpenaiKeyEncrypted: null,
|
|
} as never);
|
|
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("anthropic");
|
|
// AnthropicClient should default to its SDK baseUrl, never the LAN URL.
|
|
const inspect = provider as unknown as {
|
|
config: { baseUrl?: string };
|
|
};
|
|
expect(inspect.config.baseUrl).toBeUndefined();
|
|
});
|
|
|
|
it("CHATGPT_OAUTH selection routes through Codex when connected", async () => {
|
|
vi.mocked(decryptCodexCreds).mockReturnValue({
|
|
accessToken: "oauth-access",
|
|
refreshToken: "decrypted-refresh",
|
|
accountId: "acct-123",
|
|
expiresAt: new Date(Date.now() + 3600_000),
|
|
});
|
|
|
|
vi.mocked(prisma.user.findUnique)
|
|
.mockResolvedValueOnce({
|
|
aiProvider: "CHATGPT_OAUTH",
|
|
aiModel: null,
|
|
aiBaseUrl: null,
|
|
aiAnthropicKeyEncrypted: null,
|
|
aiLocalKeyEncrypted: null,
|
|
} as never)
|
|
.mockResolvedValueOnce({
|
|
codexAccessTokenEncrypted: "enc-access",
|
|
codexRefreshTokenEncrypted: "enc-refresh",
|
|
codexTokenExpiresAt: new Date(Date.now() + 3600000),
|
|
codexConnectionStatus: "connected",
|
|
} as never);
|
|
|
|
const provider = await resolveProvider("user-123");
|
|
expect(provider.type).toBe("codex");
|
|
});
|
|
});
|