diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 459ed2734..220a03917 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -16,6 +16,9 @@ ### Fixed - Reworded the GPT-5 Responses no-reasoning fallback developer item so it no longer says `# Juice: 0 !important` or implies a zero tool/execution budget. ([#4151](https://github.com/can1357/oh-my-pi/issues/4151)) +### Changed + +- Clarified CoreWeave Serverless Inference login instructions to persist `COREWEAVE_PROJECT` in the user's shell startup file. ## [16.2.12] - 2026-07-01 diff --git a/packages/ai/src/providers/openai-shared.ts b/packages/ai/src/providers/openai-shared.ts index 3778893e5..8e4868f88 100644 --- a/packages/ai/src/providers/openai-shared.ts +++ b/packages/ai/src/providers/openai-shared.ts @@ -17,6 +17,7 @@ import { COREWEAVE_PROJECT_HEADER, coreWeaveProjectHeaders, hasCoreWeaveProjectHeader, + removeBlankCoreWeaveProjectHeaders, } from "@oh-my-pi/pi-catalog/wire/coreweave"; import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot"; import { @@ -161,6 +162,7 @@ function resolveSakanaRequestBaseUrl(): string | undefined { } function applyCoreWeaveProjectHeader(headers: Record): void { + removeBlankCoreWeaveProjectHeaders(headers); if (hasCoreWeaveProjectHeader(headers)) { return; } diff --git a/packages/ai/src/registry/coreweave.ts b/packages/ai/src/registry/coreweave.ts index d07c2ff4c..774d556fa 100644 --- a/packages/ai/src/registry/coreweave.ts +++ b/packages/ai/src/registry/coreweave.ts @@ -5,14 +5,15 @@ import { createApiKeyLogin } from "./api-key-login"; import type { OAuthLoginCallbacks } from "./oauth/types"; import type { ProviderDefinition } from "./types"; -const PROJECT_SETUP_INSTRUCTIONS = - "Create or select a CoreWeave Serverless Inference project, set COREWEAVE_PROJECT=/ for the OpenAI-Project header, then copy your API key from account settings"; +const PROJECT_PERSIST_INSTRUCTIONS = + "add export COREWEAVE_PROJECT=/ to your shell startup file (for example ~/.zshrc, ~/.bashrc, or your shell's profile/rc file)"; +const PROJECT_SETUP_INSTRUCTIONS = `Create or select a CoreWeave Serverless Inference project, ${PROJECT_PERSIST_INSTRUCTIONS} for the OpenAI-Project header, then copy your API key from account settings`; function requireCoreWeaveProjectHeaders(): Record { const headers = coreWeaveProjectHeaders($env); if (!headers) { throw new AIError.ConfigurationError( - "CoreWeave Serverless Inference requires OpenAI-Project. Set COREWEAVE_PROJECT=/ before running /login coreweave.", + `CoreWeave Serverless Inference requires OpenAI-Project. Set COREWEAVE_PROJECT=/ before running /login coreweave. To persist it, ${PROJECT_PERSIST_INSTRUCTIONS}.`, ); } return headers; diff --git a/packages/ai/test/coreweave-login.test.ts b/packages/ai/test/coreweave-login.test.ts index 2516fc4e2..320c92a28 100644 --- a/packages/ai/test/coreweave-login.test.ts +++ b/packages/ai/test/coreweave-login.test.ts @@ -55,6 +55,9 @@ describe("CoreWeave Serverless Inference login", () => { expect(apiKey).toBe("coreweave-test-key"); expect(authMessages[0]).toContain("COREWEAVE_PROJECT=/"); + expect(authMessages[0]).toContain("~/.zshrc"); + expect(authMessages[0]).toContain("~/.bashrc"); + expect(authMessages[0]).toContain("your shell's profile/rc file"); expect(authMessages[0]).toContain("OpenAI-Project"); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -71,12 +74,13 @@ describe("CoreWeave Serverless Inference login", () => { }); }); - await expect( - loginCoreWeave({ - onPrompt: async () => "coreweave-test-key", - fetch: fetchMock, - }), - ).rejects.toThrow("Set COREWEAVE_PROJECT=/"); + const login = loginCoreWeave({ + onPrompt: async () => "coreweave-test-key", + fetch: fetchMock, + }); + + await expect(login).rejects.toThrow("Set COREWEAVE_PROJECT=/"); + await expect(login).rejects.toThrow("your shell's profile/rc file"); expect(fetchMock).toHaveBeenCalledTimes(0); }); diff --git a/packages/ai/test/coreweave-project-header.test.ts b/packages/ai/test/coreweave-project-header.test.ts index a01c63ee1..881ff5fe3 100644 --- a/packages/ai/test/coreweave-project-header.test.ts +++ b/packages/ai/test/coreweave-project-header.test.ts @@ -1,5 +1,8 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test, vi } from "bun:test"; +import { completeSimple } from "@oh-my-pi/pi-ai"; import { resolveOpenAIRequestSetup } from "@oh-my-pi/pi-ai/providers/openai-shared"; +import type { Context, FetchImpl } from "@oh-my-pi/pi-ai/types"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; const COREWEAVE_ENV_KEYS = ["COREWEAVE_PROJECT", "WANDB_INFERENCE_PROJECT", "WANDB_ENTITY", "WANDB_PROJECT"] as const; const ORIGINAL_ENV = new Map(COREWEAVE_ENV_KEYS.map(key => [key, Bun.env[key]])); @@ -19,6 +22,22 @@ afterEach(() => { restoreCoreWeaveEnv(); }); +const context: Context = { messages: [{ role: "user", content: "hi", timestamp: 0 }] }; + +function chatSse(): Response { + const chunk = (delta: unknown, finish: string | null) => + JSON.stringify({ + id: "x", + object: "chat.completion.chunk", + created: 0, + choices: [{ index: 0, delta, finish_reason: finish }], + }); + return new Response(`data: ${chunk({ content: "ok" }, null)}\n\ndata: ${chunk({}, "stop")}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + describe("CoreWeave Serverless Inference project header", () => { const coreWeaveModel = { provider: "coreweave", @@ -66,4 +85,34 @@ describe("CoreWeave Serverless Inference project header", () => { expect(setup.headers["openai-project"]).toBe("explicit/team"); expect(setup.headers["OpenAI-Project"]).toBeUndefined(); }); + + test("uses COREWEAVE_PROJECT when an explicit blank project header is present", () => { + Bun.env.COREWEAVE_PROJECT = "team/project"; + + const setup = resolveOpenAIRequestSetup(coreWeaveModel, { + apiKey: "coreweave-key", + extraHeaders: { "openai-project": " " }, + messages: [], + }); + + expect(setup.headers["OpenAI-Project"]).toBe("team/project"); + expect(setup.headers["openai-project"]).toBeUndefined(); + }); + + test("sends one OpenAI-Project header on chat-completions requests when a blank override is present", async () => { + Bun.env.COREWEAVE_PROJECT = "team/project"; + const requestHeaders: Headers[] = []; + const fetchMock: FetchImpl = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + requestHeaders.push(new Headers(init?.headers)); + return chatSse(); + }); + + await completeSimple(getBundledModel("coreweave", "zai-org/GLM-5.2"), context, { + apiKey: "coreweave-key", + fetch: fetchMock, + headers: { "openai-project": " " }, + }); + + expect(requestHeaders[0]?.get("OpenAI-Project")).toBe("team/project"); + }); }); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index d6b66b194..a8c96e194 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -17,6 +17,9 @@ ### Fixed - Updated the Responses compat flag docs to describe the no-reasoning fallback without the old `# Juice: 0 !important` wording. ([#4151](https://github.com/can1357/oh-my-pi/issues/4151)) +### Fixed + +- Fixed CoreWeave Serverless Inference project-header detection so blank `OpenAI-Project` overrides do not block `COREWEAVE_PROJECT` fallback. ## [16.2.12] - 2026-07-01 diff --git a/packages/catalog/src/wire/coreweave.ts b/packages/catalog/src/wire/coreweave.ts index 8dadadb05..c12ad4f10 100644 --- a/packages/catalog/src/wire/coreweave.ts +++ b/packages/catalog/src/wire/coreweave.ts @@ -38,5 +38,14 @@ export function coreWeaveProjectHeaders(env: CoreWeaveProjectEnv): Record): boolean { const normalized = COREWEAVE_PROJECT_HEADER.toLowerCase(); - return Object.keys(headers).some(header => header.toLowerCase() === normalized); + return Object.entries(headers).some(([header, value]) => header.toLowerCase() === normalized && value.trim() !== ""); +} + +export function removeBlankCoreWeaveProjectHeaders(headers: Record): void { + const normalized = COREWEAVE_PROJECT_HEADER.toLowerCase(); + for (const [header, value] of Object.entries(headers)) { + if (header.toLowerCase() === normalized && value.trim() === "") { + delete headers[header]; + } + } }