Repository navigation
[package build] #4997 on 018311cf — not for merge #4998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: rui/hosted-bots-git-on-5efe7424
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "eve": patch | ||
| --- | ||
|
|
||
| `eve dev` can take its AI Gateway credential from the program that starts it: set `EVE_MODEL_CREDENTIAL_URL` and `EVE_MODEL_CREDENTIAL_TOKEN`, and every Gateway model call, including compaction, uses the credential that URL serves, ahead of every other connection. When Gateway refuses the token, eve asks the URL for a fresh one. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { createHash } from "node:crypto"; | ||
| import { afterEach, beforeEach, expect, it, vi } from "vitest"; | ||
| vi.mock("./store.js", () => ({ | ||
| readModelSecret: vi.fn(), | ||
| modelKeySecretName: (name: string) => name, | ||
| })); | ||
| import { resolveGatewayModelCredential } from "./gateway-credential.js"; | ||
| import { localGatewayModel } from "./transport.js"; | ||
|
|
||
| const fetch = vi.fn(); | ||
| beforeEach(() => { | ||
| vi.stubGlobal("fetch", fetch); | ||
| vi.stubEnv("EVE_DEV", "1"); | ||
| vi.stubEnv("EVE_MODEL_CONNECTION", ""); | ||
| vi.stubEnv("AI_GATEWAY_API_KEY", "machine-key"); | ||
| vi.stubEnv("EVE_MODEL_CREDENTIAL_URL", "https://hub.example/api/model-credential"); | ||
| vi.stubEnv("EVE_MODEL_CREDENTIAL_TOKEN", "bot-token"); | ||
|
Comment on lines
+11
to
+17
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These tests depend on the environment they run in: |
||
| }); | ||
| afterEach(() => { | ||
| vi.unstubAllEnvs(); | ||
| vi.unstubAllGlobals(); | ||
| fetch.mockReset(); | ||
| }); | ||
|
|
||
| it("takes the gateway credential from the served URL over any machine credential", async () => { | ||
| fetch.mockResolvedValue(Response.json({ kind: "oauth", token: "owner-token", teamId: "team_1" })); | ||
| expect(await resolveGatewayModelCredential()).toEqual({ | ||
| kind: "oauth", | ||
| token: "owner-token", | ||
| teamId: "team_1", | ||
| }); | ||
| const [url, init] = fetch.mock.calls[0]!; | ||
| expect(url).toBe("https://hub.example/api/model-credential"); | ||
| expect(init.headers).toEqual({ authorization: "Bearer bot-token" }); | ||
| }); | ||
|
|
||
| it("names a rejected token by its hash so the server refreshes it", async () => { | ||
| fetch.mockResolvedValue(Response.json({ kind: "oauth", token: "fresh" })); | ||
| await resolveGatewayModelCredential("stale"); | ||
| expect(fetch.mock.calls[0]![1].headers).toEqual({ | ||
| authorization: "Bearer bot-token", | ||
| "x-eve-model-rejected-token-sha256": createHash("sha256").update("stale").digest("hex"), | ||
| }); | ||
| }); | ||
|
|
||
| it("fails with the server's own error", async () => { | ||
| fetch.mockResolvedValue( | ||
| Response.json({ error: "the Vercel sign-in needs renewing in the app" }, { status: 401 }), | ||
| ); | ||
| await expect(resolveGatewayModelCredential()).rejects.toThrow( | ||
| "the Vercel sign-in needs renewing in the app", | ||
| ); | ||
| }); | ||
|
|
||
| it("refuses a served URL without its token", async () => { | ||
| vi.stubEnv("EVE_MODEL_CREDENTIAL_TOKEN", ""); | ||
| await expect(resolveGatewayModelCredential()).rejects.toThrow("EVE_MODEL_CREDENTIAL_TOKEN"); | ||
| expect(fetch).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("routes local models through the gateway when only the served URL is set", () => { | ||
| vi.stubEnv("AI_GATEWAY_API_KEY", ""); | ||
| expect(localGatewayModel("anthropic/claude-sonnet-4.5")).toBeDefined(); | ||
| vi.stubEnv("EVE_MODEL_CREDENTIAL_URL", ""); | ||
| expect(localGatewayModel("anthropic/claude-sonnet-4.5")).toBeUndefined(); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,25 @@ | ||
| import { getVercelOidcToken } from "#compiled/@vercel/oidc/index.js"; | ||
| import { MODEL_CONNECTION_ENV } from "#shared/model-helper.js"; | ||
| import { resolveModelApiKey } from "./api-key.js"; | ||
| import { fetchModelCredential } from "./development-broker-client.js"; | ||
| import { readVercelCliConnection, refreshVercelCliConnection } from "./vercel-cli.js"; | ||
| import { resolveVercelSession } from "./vercel.js"; | ||
| import type { DevelopmentModelCredential } from "./development-broker-protocol.js"; | ||
|
|
||
| /** Set by a process that launches eve and serves its credential: it outranks every other source. */ | ||
| export const MODEL_CREDENTIAL_URL_ENV = "EVE_MODEL_CREDENTIAL_URL"; | ||
| export const MODEL_CREDENTIAL_TOKEN_ENV = "EVE_MODEL_CREDENTIAL_TOKEN"; | ||
|
|
||
| export async function resolveGatewayModelCredential( | ||
| rejectedToken?: string, | ||
| ): Promise<DevelopmentModelCredential> { | ||
| const served = process.env[MODEL_CREDENTIAL_URL_ENV]; | ||
| if (served) { | ||
| const token = process.env[MODEL_CREDENTIAL_TOKEN_ENV]; | ||
| if (!token) | ||
| throw new Error(`${MODEL_CREDENTIAL_URL_ENV} is set without ${MODEL_CREDENTIAL_TOKEN_ENV}.`); | ||
| return fetchModelCredential(served, { authorization: `Bearer ${token}` }, rejectedToken); | ||
|
Comment on lines
+16
to
+21
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This sends the bearer token to any URL and accepts an OAuth token back, with no check on scheme or host. |
||
| } | ||
| const selected = process.env[MODEL_CONNECTION_ENV]; | ||
| if (selected === "vercel" || selected === "vercel-cli") { | ||
| if (selected === "vercel-cli" && rejectedToken) await refreshVercelCliConnection(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| // A served credential URL (EVE_MODEL_CREDENTIAL_URL) on the path a dev turn | ||
| // takes: the worker's Gateway model asks eve dev's credential relay, and the | ||
| // relay asks the served URL with the real resolver. | ||
| import { createHash } from "node:crypto"; | ||
| import { afterEach, beforeEach, expect, it, vi } from "vitest"; | ||
| import { DEVELOPMENT_WORKFLOW_SECRET_ENV } from "#internal/workflow/development-world-protocol.js"; | ||
| import { DEVELOPMENT_MODEL_REJECTED_HEADER } from "./development-broker-protocol.js"; | ||
|
|
||
| vi.mock("#cli/dev/environment.js", () => ({ loadDevelopmentEnvironmentFiles: vi.fn() })); | ||
| vi.mock("./store.js", () => ({ readModelSecret: vi.fn(), modelKeySecretName: (n: string) => n })); | ||
| vi.mock("#public/models/openai/chatgpt/token-broker.js", () => ({ | ||
| getDefaultCodexTokenBroker: vi.fn(), | ||
| })); | ||
| import { handleDevelopmentModelCredentialRequest } from "./development-broker-server.js"; | ||
| import { readDevelopmentModelCredential } from "./development-broker-client.js"; | ||
| import { localGatewayModel } from "./transport.js"; | ||
|
|
||
| const origin = "http://localhost:4567"; | ||
| const secret = "fixture-transport-secret"; | ||
| const served = "https://hub.example/api/model-credential"; | ||
| const hub = vi.fn<(request: Request) => Promise<Response>>(); | ||
| const gateway = vi.fn<(url: string, init: RequestInit) => Promise<Response>>(); | ||
| beforeEach(() => { | ||
| vi.stubEnv("EVE_DEV", "1"); | ||
| vi.stubEnv("EVE_DEV_CONTROL_URL", origin); | ||
| vi.stubEnv(DEVELOPMENT_WORKFLOW_SECRET_ENV, secret); | ||
| vi.stubEnv("EVE_MODEL_CONNECTION", ""); | ||
| vi.stubEnv("AI_GATEWAY_API_KEY", ""); | ||
| vi.stubEnv("EVE_MODEL_CREDENTIAL_URL", served); | ||
| vi.stubEnv("EVE_MODEL_CREDENTIAL_TOKEN", "bot-token"); | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn(async (url: Parameters<typeof fetch>[0], init?: RequestInit) => { | ||
| const request = new Request(url, init); | ||
| if (request.url.startsWith(origin)) | ||
| return ( | ||
| (await handleDevelopmentModelCredentialRequest(request, { | ||
| appRoot: "/fixture", | ||
| secret, | ||
| })) ?? new Response(null, { status: 404 }) | ||
| ); | ||
| if (request.url === served) return hub(request); | ||
| return gateway(String(url), init ?? {}); | ||
| }), | ||
| ); | ||
| }); | ||
| afterEach(() => { | ||
| vi.unstubAllEnvs(); | ||
| vi.unstubAllGlobals(); | ||
| hub.mockReset(); | ||
| gateway.mockReset(); | ||
| }); | ||
|
|
||
| it("refreshes a token Gateway refused by asking the served URL with its hash", async () => { | ||
| const stale = createHash("sha256").update("stale").digest("hex"); | ||
| // The served URL hands out its live token until it is named as refused. | ||
| hub.mockImplementation(async (request) => | ||
| Response.json({ | ||
| kind: "oauth", | ||
| token: request.headers.get(DEVELOPMENT_MODEL_REJECTED_HEADER) === stale ? "fresh" : "stale", | ||
| }), | ||
| ); | ||
| // The transport reuses one Headers across its retry, so read it as each call is sent. | ||
| const sent: (string | null)[] = []; | ||
| gateway.mockImplementation(async (_url, init) => | ||
| sent.push(new Headers(init.headers).get("authorization")) && sent.at(-1) === "Bearer stale" | ||
| ? new Response(null, { status: 401 }) | ||
| : new Response( | ||
| 'data: {"type":"text-start","id":"t"}\n\ndata: {"type":"text-delta","id":"t","delta":"pong"}\n\ndata: {"type":"text-end","id":"t"}\n\n', | ||
| { | ||
| headers: { "content-type": "text/event-stream" }, | ||
| }, | ||
| ), | ||
| ); | ||
| const model = localGatewayModel("anthropic/claude-sonnet-4.5"); | ||
| if (!model || typeof model === "string") throw new Error("Expected a Gateway model"); | ||
| const result = await model.doStream({ | ||
| prompt: [{ role: "user", content: [{ type: "text", text: "ping" }] }], | ||
| }); | ||
| let text = ""; | ||
| for await (const event of result.stream) { | ||
| if (event.type === "error") throw event.error; | ||
| if (event.type === "text-delta") text += event.delta; | ||
| } | ||
| expect(text).toBe("pong"); | ||
| expect(sent).toEqual(["Bearer stale", "Bearer fresh"]); | ||
| expect(hub.mock.calls.map(([r]) => r.headers.get("authorization"))).toEqual( | ||
| Array(3).fill("Bearer bot-token"), | ||
| ); | ||
| expect(hub.mock.calls.map(([r]) => r.headers.get(DEVELOPMENT_MODEL_REJECTED_HEADER))).toEqual([ | ||
| null, | ||
| null, | ||
| stale, | ||
| ]); | ||
| }); | ||
|
|
||
| it("brings the served URL's own error to the turn", async () => { | ||
| hub.mockResolvedValue( | ||
| Response.json({ error: "the Vercel sign-in needs renewing in the app" }, { status: 401 }), | ||
| ); | ||
| await expect(readDevelopmentModelCredential("gateway")).rejects.toThrow( | ||
| "the Vercel sign-in needs renewing in the app", | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"for every Gateway model call, including compaction" claims more than the code does. The override lives in
localGatewayModel, and only string model IDs reach it (runtime/agent/resolve-model.ts:74). An authored model instance such asgateway("..."), or a dynamic selection that returns a model object, is returned as-is (resolve-model.ts:70-71,:113) and never fetches from this URL. The same goes for acompaction.modelinstance. Suggest "for every model call that names a Gateway model by its string ID, including compaction".