From e2a4dbffa577425e11efa3477bcc6cd04cde6cda Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Wed, 23 Sep 2026 23:38:57 +0200 Subject: [PATCH 1/2] feat(provider): connect a local endpoint that needs no authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model served on a plain OpenAI-compatible endpoint — Ollama, llama.cpp, vLLM, LM Studio — needs no credential, and the runtime refused to connect one at three independent layers: `provider add` demanded an API key, the connection test reported `Provider API key is not configured`, and a turn failed in `LocalModelResolver` with `api_key not configured`. A key is now optional everywhere a provider is created, saved, tested or resolved. Nothing infers "no authentication" from a URL and no new vocabulary was introduced: an entry that carries no key simply declares no credential scheme, and every request to it is sent without one. - `packages/shared/src/credential-headers.ts` (new) names the credential headers the three protocols use, clears them, and owns the placeholder key the vendored transport requires (`openai-completions` throws on a falsy key). - Resolution: the BYOK plan and the managed path resolve a keyless endpoint to that placeholder plus `unauthenticatedEndpoint`, which `composeStreamFn` turns into cleared credential headers on the request's own options — the last header source pi-ai merges, and the one its SDKs read a `null` from as "remove this default header". A credential the connection declares itself survives. - Auxiliary calls stay credential-free too: the remote token counter skips its probe for such an endpoint (its requests are credential-shaped and it estimates locally instead), and the connectivity paths (`buildProviderHeaders`, `buildModelDiscoveryHeaders`, the connection test, model discovery) omit the credential header when no key is configured. - Management: a candidate or a created provider without a key is stored with no `apiKey` and no `authMode`, and an empty key clears a saved one instead of being rejected as invalid. A keyless connection is selectable; the connection test is the gate, as it already is for a failed test. - CLI: `provider add` no longer requires a key or `--api-key-env`, and `provider list` reports `no key sent` rather than `no key`. - TUI: the catalogue gains a **Local model** entry that pre-fills an OpenAI-compatible endpoint (editable, Ollama's default port) and skips the protocol step; the API Key step is labelled optional and an empty value connects; the `/provider` editor tests and saves a connection with no key and clears a saved one when its API Key field is submitted empty. Coverage: a new agent-core case set pins the header clearing, the resolver and plan cases pin the keyless plan and the placeholder, the counter case pins the local estimate, the management cases pin the saved shape and the selectable connection, the TUI cases pin the entry, the optional key step and the cleared key, and `test:byok` runs the whole path against a loopback stand-in — `provider add`, `provider list`, the connection test and a turn — asserting that no request carries `Authorization` or `x-api-key`. --- README.md | 2 +- docs/examples.md | 5 +- docs/tui-capabilities.md | 2 +- packages/agent-core/src/pi-turn-runner/llm.ts | 33 +++- .../agent-core/src/pi-turn-runner/types.ts | 9 + .../unauthenticated-endpoint.test.ts | 180 ++++++++++++++++++ .../process-local-application-contract.ts | 3 +- .../connectivity/provider-request.ts | 17 +- .../connectivity/test-connection.ts | 1 + .../src/service/model-system/contracts.ts | 6 +- .../management/service-context.ts | 34 ++-- .../service-custom-provider-operations.ts | 14 +- .../model-system/management/service.test.ts | 61 +++++- .../model-system/management/service.ts | 3 +- .../resolution/local-model-resolver.test.ts | 70 ++++++- .../resolution/local-model-resolver.ts | 38 +++- .../resolution/model-resolver-byok.test.ts | 32 +++- .../resolution/model-resolver-byok.ts | 20 +- .../src/context/remote-token-counter.ts | 12 +- .../token-counter-adapter-routing.test.ts | 34 ++++ packages/shared/src/credential-headers.ts | 42 ++++ packages/shared/src/index.ts | 5 + packages/tui/src/cli/provider-command.ts | 12 +- packages/tui/src/provider/contract.ts | 6 +- .../tui/src/tui/features/provider/editor.ts | 40 +++- .../tui/src/tui/features/provider/manager.ts | 2 +- .../src/tui/features/provider/onboarding.ts | 70 +++++-- .../tui/test/unit/tui-provider-editor.test.ts | 39 +++- .../test/unit/tui-provider-onboarding.test.ts | 54 ++++++ release/public-source.json | 2 + test/byok.test.mjs | 155 +++++++++++++++ test/vitest-suites.json | 1 + 32 files changed, 911 insertions(+), 93 deletions(-) create mode 100644 packages/agent-core/test/unit/pi-turn-runner/unauthenticated-endpoint.test.ts create mode 100644 packages/shared/src/credential-headers.ts diff --git a/README.md b/README.md index 98f26d8d..a32a544e 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ kcode `--use` tests the first listed model before saving and selecting it. A failed connection test saves nothing. Omit `--use` to save without testing or changing the default model. For custom/local models, add `--context-limit 32768 --output-limit 4096` (use your server's actual limits). Each value must be a positive safe integer and applies to every repeated `--model`. Inspect configured limits with `kcode provider list --json`. Omitting these flags preserves the existing model-limit defaults. -Supported API formats: `openai-completions`, `openai-responses`, and `anthropic-messages`. See the [model examples](docs/examples.md#2-choose-your-own-model) for environment variable setup, connection checks, and model overrides for a single run. +Supported API formats: `openai-completions`, `openai-responses`, and `anthropic-messages`. An endpoint that needs no authentication — a server you run locally, typically — is a provider without a key: omit `--api-key-env`, and no credential header is sent. See the [model examples](docs/examples.md#2-choose-your-own-model) for environment variable setup, connection checks, and model overrides for a single run. diff --git a/docs/examples.md b/docs/examples.md index 32c2d6a6..6fe7a16e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -56,16 +56,17 @@ pnpm kcode exec "Explain this project's test entry points" --model Replace the example URL, model name, and IDs with your configuration and the IDs returned by the list command. `--use` tests the first listed model, then saves the provider and selects that model as the default. A failed connection test exits nonzero without saving or changing the default; correct the URL, key, or first model ID and retry. Omit `--use` to save without a connection test or default-model change. `exec --model` overrides only the current run. Backslash line continuations are for POSIX shells; use a single line in PowerShell. -For a local server, configure its actual token limits explicitly: +A local server that checks no credential needs no key: omit `--api-key-env`, and configure its actual token limits explicitly: ```bash pnpm kcode provider add --name local-models --base-url http://localhost:8080/v1 \ --api-format openai-completions --model local-model --model another-model \ - --api-key-env MCODE_PROVIDER_API_KEY \ --context-limit 32768 --output-limit 4096 --use pnpm kcode provider list --json ``` +The provider is stored with no `apiKey` and no `authMode`, and every request to it — the connection test, model discovery, and a turn — is sent with no `Authorization` and no `x-api-key` header. Pass `--api-key-env ` when the server behind the URL does check a credential; an explicitly named variable that holds nothing is an error, because the key was asked for and not found. The same choice exists in the TUI: the catalogue's **Local model** entry pre-fills an OpenAI-compatible endpoint, skips the protocol step, and leaves the API Key field empty, while the `/provider` editor shows an entry without a key as `Not set · a request carries no credential`, and an empty API Key field there removes a saved key. + `--context-limit` and `--output-limit` each accept a positive safe integer (at most `9007199254740991`). Either flag can be used independently. The same limits apply to every repeated `--model`; only the first model is tested and selected by `--use`. The JSON list shows the configured values as `contextLimit` and `maxOutputTokens`. Without these flags, the existing defaults remain unchanged (unknown custom models currently fall back to 200,000 context tokens and 16,384 output tokens). Model discovery does not infer your local server's context size. `--api-key-env` reads the current environment variable value and stores that value in the active profile's `config.yaml`; it does not save an environment-variable reference. The file still contains plaintext credentials. On POSIX systems, config writes and temporary copies use `0600`. When loading existing files, KCode removes group/other access while preserving the owner's permissions; already-private files such as `0400` or `0600` do not require a permission change. Loading fails if an unsafe main config cannot be restricted. Older migration backups are also checked, but inspection or repair failures produce a warning identifying the directory or backup that needs manual attention rather than preventing the main config from loading. Windows file modes do not provide equivalent ACL protection; restrict access to the profile directory using Windows permissions. diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index de7621f8..39b2ab3f 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -7,7 +7,7 @@ The evidence column summarizes the historical TUI 0.3.11 restoration record from | Capability | Implementation | Evidence | | --- | --- | --- | | MiniMax login, logout, Token Plan, quota, check-in | OAuth Core, account clients, and TUI / CLI entry points restored; `/login ` names the sign-in it starts, and every other provider is connected in `/provider` | Login-state, auth-command, check-in, provider, and ACP tests; production Token Plan session and resume passed | -| BYOK / custom models | Retained; headless model overrides no longer inherit the default Token Plan login requirement. `/provider` lists every connection the runtime resolves — entries the `provider` tree of `config.yaml` declares, and the user's `custom_provider` connections — tests each against its own endpoint and key, and connects a new provider from the same catalogue the model picker offers | Local protocol server drives runtime, resume, and file tools; live BYOK session passed; managed models still reject unauthenticated requests. The panel's list, test and connect paths are covered by unit tests and by a built CLI against an isolated config; the endpoint a builtin entry is tested against is a local stand-in, not a live service | +| BYOK / custom models | Retained; headless model overrides no longer inherit the default Token Plan login requirement. `/provider` lists every connection the runtime resolves — entries the `provider` tree of `config.yaml` declares, and the user's `custom_provider` connections — tests each against its own endpoint and key, and connects a new provider from the same catalogue the model picker offers, including a **Local model** entry for an OpenAI-compatible server. A key is optional everywhere: an entry saved without one declares no credential scheme, and no request to it carries `Authorization` or `x-api-key`; the `/provider` editor marks such an entry `Not set · a request carries no credential` and removes a saved key when its API Key field is submitted empty | Local protocol server drives runtime, resume, and file tools; live BYOK session passed; managed models still reject unauthenticated requests. The panel's list, test and connect paths are covered by unit tests and by a built CLI against an isolated config; the endpoint a builtin entry is tested against is a local stand-in, not a live service. A keyless connection is covered end to end by `test:byok` against a loopback stand-in: add, list, connection test and a turn, with every request asserting the absence of both credential headers | | GitHub Copilot provider (fork addition) | Connector signs in to a Copilot subscription and reads the account's live `/models` catalog: per-model context and output limits, the reasoning-effort levels the account reports, and the wire protocol each model advertises. Sign-in is the GitHub device flow through pi's Copilot OAuth provider, or a token supplied out of band; the credential stays in the provider credential store | Discovery and connector tests run against a captured catalog; live completions passed on `openai-responses`, `anthropic-messages` and `openai-completions`; `kcode exec` passed end to end on all three protocols and in all four egress modes. Both TUI entry points carry the connect action and run the device flow: the model picker row, and a `/provider` row that Runtime synthesizes from the sign-in status before any entry exists. Once the connector has written its entry, that entry is the only Copilot row — Codex instead keeps its synthetic row beside the entry. The enterprise API host is unit-tested only | | mcode-tools | Public package's unchanged 0.0.4 artifact, launcher, and short-lived token leases restored | Archive SHA-512, CLI SHA-256, real CLI startup, lease and host integration tests; production shared-broker authentication passed | | Search and Matrix image / audio / video tools | Matrix MCP and tool assembly restored; search is independent of mcode-tools | MCP configuration and auth-isolation tests; actual search passed; generation requests not run | diff --git a/packages/agent-core/src/pi-turn-runner/llm.ts b/packages/agent-core/src/pi-turn-runner/llm.ts index 142522a3..62e510fb 100644 --- a/packages/agent-core/src/pi-turn-runner/llm.ts +++ b/packages/agent-core/src/pi-turn-runner/llm.ts @@ -1,5 +1,6 @@ import type { Agent, AgentMessage, StreamFn } from '@earendil-works/pi-agent-core'; import { streamSimple, type CacheRetention, type SimpleStreamOptions } from '@earendil-works/pi-ai'; +import { withClearedCredentialHeaders } from '@mavis/shared'; import { LLM_REQUEST_TIMEOUT_MS } from './defaults.js'; import type { PiAfterLlmReplacementCommit, @@ -14,10 +15,15 @@ export function composeStreamFn(resolved: LLMModelConfig): StreamFn { // Order matters: each wrapper only fills the option it owns, while // explicit per-call options from upstream wrappers still win. The host // ceiling is the exception and therefore the innermost link: it has to see - // the fully accumulated options in order to shrink them. + // the fully accumulated options in order to shrink them. Clearing a + // credential header of an endpoint that needs no authentication sits below + // even that, because it must see every header the request would carry. const callerHeaders = resolved.headers && Object.keys(resolved.headers).length > 0 ? resolved.headers : undefined; - const hostClamped = withHostMaxOutputTokens(resolved.streamFn, resolved.hostMaxOutputTokens); + const baseStream = resolved.unauthenticatedEndpoint + ? withUnauthenticatedEndpointHeaders(resolved.streamFn) + : resolved.streamFn; + const hostClamped = withHostMaxOutputTokens(baseStream, resolved.hostMaxOutputTokens); const cacheWrapped = withCacheRetention(hostClamped, resolved.cacheRetention); const maxTokensWrapped = typeof resolved.maxTokens === 'number' && resolved.maxTokens > 0 @@ -539,6 +545,29 @@ function withHeaders(inner: StreamFn | undefined, headers: Record { + const headers = withClearedCredentialHeaders( + options?.headers as Readonly> | undefined, + ); + return base(model, context, { + ...(options ?? {}), + // pi-ai types these as strings; a null is forwarded to the SDK untouched. + headers: headers as Record, + }); + }) as StreamFn; +} + function withFetch(inner: StreamFn | undefined, fetch: SimpleStreamOptions['fetch']): StreamFn { const base = inner ?? streamSimple; return ((model, context, options) => { diff --git a/packages/agent-core/src/pi-turn-runner/types.ts b/packages/agent-core/src/pi-turn-runner/types.ts index b8965049..40e43ee3 100644 --- a/packages/agent-core/src/pi-turn-runner/types.ts +++ b/packages/agent-core/src/pi-turn-runner/types.ts @@ -52,6 +52,15 @@ export interface LLMModelConfig { */ hostMaxOutputTokens?: number; headers?: Record; + /** + * The endpoint this model runs on needs no credential. + * + * The provider SDKs refuse to build a client without a key, so the resolver + * hands them one that is never meant to be sent; this flag is what keeps that + * placeholder off the wire, by clearing the credential headers each request + * would otherwise carry. + */ + unauthenticatedEndpoint?: true; fetch?: SimpleStreamOptions['fetch']; /** Provider payload transform for the main assistant response. */ payloadTransform?: SimpleStreamOptions['onPayload']; diff --git a/packages/agent-core/test/unit/pi-turn-runner/unauthenticated-endpoint.test.ts b/packages/agent-core/test/unit/pi-turn-runner/unauthenticated-endpoint.test.ts new file mode 100644 index 00000000..c4411ca2 --- /dev/null +++ b/packages/agent-core/test/unit/pi-turn-runner/unauthenticated-endpoint.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import type { + AssistantMessage, + AssistantMessageEvent, + AssistantMessageEventStream, + Context, + Model, +} from "@earendil-works/pi-ai"; +import { CREDENTIAL_HEADER_NAMES, withClearedCredentialHeaders } from "@mavis/shared"; +import { composeStreamFn } from "../../../src/pi-turn-runner/llm.js"; +import type { LLMModelConfig } from "../../../src/pi-turn-runner/types.js"; + +function fakeModel(): Model { + return { + id: "local-model", + name: "local-model", + api: "openai-completions", + provider: "custom_provider:local", + baseUrl: "http://127.0.0.1:11434/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_000, + maxTokens: 4_096, + } as Model; +} + +const CONTEXT: Context = { messages: [] }; + +function finalMessage(): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "ok" }], + api: "openai-completions", + provider: "custom_provider:local", + model: "local-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: 0, + } as AssistantMessage; +} + +function stream(): AssistantMessageEventStream { + const events: AssistantMessageEvent[] = []; + const final = finalMessage(); + return { + [Symbol.asyncIterator]() { + let index = 0; + return { + async next() { + if (index < events.length) + return { value: events[index++], done: false }; + return { value: undefined, done: true }; + }, + } as AsyncIterableIterator; + }, + async result() { + return final; + }, + } as unknown as AssistantMessageEventStream; +} + +/** Records every option set the composed stream function hands the provider. */ +function capturingStreamFn(captured: Array>): StreamFn { + return (async (_model, _context, options) => { + captured.push((options ?? {}) as Record); + return stream(); + }) as StreamFn; +} + +function modelConfig( + captured: Array>, + overrides: Partial = {}, +): LLMModelConfig { + return { + model: fakeModel(), + apiKey: "kcode-no-auth", + streamFn: capturingStreamFn(captured), + ...overrides, + }; +} + +describe("composeStreamFn on an endpoint that needs no authentication", () => { + it("clears both credential headers while still handing the transport its key", async () => { + const captured: Array> = []; + const composed = composeStreamFn( + modelConfig(captured, { unauthenticatedEndpoint: true }), + ); + + // The turn hands the transport the placeholder key it needs to build a + // client; the credential header that would carry it is what gets cleared. + await composed(fakeModel(), CONTEXT, { apiKey: "kcode-no-auth" }); + + expect(captured).toHaveLength(1); + expect(captured[0].apiKey).toBe("kcode-no-auth"); + expect(captured[0].headers).toEqual({ authorization: null, "x-api-key": null }); + }); + + it("clears the credential headers a caller passes, keeping its other headers", async () => { + const captured: Array> = []; + const composed = composeStreamFn( + modelConfig(captured, { + headers: { "x-session-id": "session-1" }, + unauthenticatedEndpoint: true, + }), + ); + + // The compaction path re-supplies a key and headers on the call itself. + await composed(fakeModel(), CONTEXT, { + apiKey: "kcode-no-auth", + headers: { "x-trace": "trace-1" }, + }); + + expect(captured[0].apiKey).toBe("kcode-no-auth"); + expect(captured[0].headers).toEqual({ + "x-session-id": "session-1", + authorization: null, + "x-trace": "trace-1", + "x-api-key": null, + }); + }); + + it("keeps a credential header the caller declared on purpose", async () => { + const captured: Array> = []; + const composed = composeStreamFn( + modelConfig(captured, { + headers: { Authorization: "Bearer relay-token" }, + unauthenticatedEndpoint: true, + }), + ); + + await composed(fakeModel(), CONTEXT, {}); + + expect(captured[0].headers).toEqual({ + Authorization: "Bearer relay-token", + "x-api-key": null, + }); + }); + + it("leaves every header untouched for an authenticated endpoint", async () => { + const captured: Array> = []; + const composed = composeStreamFn(modelConfig(captured)); + + await composed(fakeModel(), CONTEXT, { headers: { "x-trace": "trace-1" } }); + + expect(captured[0].headers).toEqual({ "x-trace": "trace-1" }); + }); +}); + +describe("withClearedCredentialHeaders", () => { + it("names both credential headers the supported protocols use", () => { + expect([...CREDENTIAL_HEADER_NAMES]).toEqual(["authorization", "x-api-key"]); + }); + + it("is case-insensitive about headers the caller already set", () => { + expect(withClearedCredentialHeaders({ Authorization: "Bearer relay" })).toEqual({ + Authorization: "Bearer relay", + "x-api-key": null, + }); + expect(withClearedCredentialHeaders({ "X-Api-Key": "relay" })).toEqual({ + "X-Api-Key": "relay", + authorization: null, + }); + }); + + it("works without headers at all", () => { + expect(withClearedCredentialHeaders(undefined)).toEqual({ + authorization: null, + "x-api-key": null, + }); + }); +}); diff --git a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts index d7455ba8..3df9cc7d 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts @@ -269,7 +269,8 @@ export interface LocalRuntimeApplication { create(input: { name?: string; baseUrl: string; - apiKey: string; + /** Absent saves an endpoint that needs no authentication. */ + apiKey?: string; apiFormat?: string; models?: readonly ProcessLocalModelInput[]; saveAndUse?: boolean; diff --git a/packages/local-runtime-v2/src/service/model-system/connectivity/provider-request.ts b/packages/local-runtime-v2/src/service/model-system/connectivity/provider-request.ts index 901ef380..cf420c4f 100644 --- a/packages/local-runtime-v2/src/service/model-system/connectivity/provider-request.ts +++ b/packages/local-runtime-v2/src/service/model-system/connectivity/provider-request.ts @@ -4,24 +4,27 @@ import type { ModelProviderApi } from '../identity.js'; export type { ModelProviderApi } from '../identity.js'; +export { UNAUTHENTICATED_PROVIDER_API_KEY } from '@mavis/shared'; + const MESSAGES_VERSION_HEADER = 'anthropic-messages'.replace('-messages', '-version'); export function buildProviderHeaders(input: { api: ModelProviderApi; - apiKey: string; + apiKey?: string; baseUrl?: string; headers?: Record; }): Headers { + const credential = input.apiKey?.trim(); const defaults: Record = input.api === 'anthropic-messages' ? { 'content-type': 'application/json', - 'x-api-key': input.apiKey, + ...(credential ? { 'x-api-key': credential } : {}), [MESSAGES_VERSION_HEADER]: '2023-06-01', } : { 'content-type': 'application/json', - Authorization: `Bearer ${input.apiKey}`, + ...(credential ? { Authorization: `Bearer ${credential}` } : {}), }; const headers = new Headers(defaults); const attributedHeaders = withOpenCodeGoHeaders( @@ -36,13 +39,15 @@ export function buildProviderHeaders(input: { export function buildModelDiscoveryHeaders(input: { api: ModelProviderApi; - apiKey: string; + apiKey?: string; baseUrl?: string; headers?: Record; }): Headers { const headers = buildProviderHeaders(input); - if (!headers.has('authorization')) headers.set('authorization', `Bearer ${input.apiKey}`); - if (!headers.has('x-api-key')) headers.set('x-api-key', input.apiKey); + const credential = input.apiKey?.trim(); + if (!credential) return headers; + if (!headers.has('authorization')) headers.set('authorization', `Bearer ${credential}`); + if (!headers.has('x-api-key')) headers.set('x-api-key', credential); return headers; } diff --git a/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.ts b/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.ts index 0e4fffe9..c7e46bcb 100644 --- a/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.ts +++ b/packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.ts @@ -160,6 +160,7 @@ function redactConnectionTestError( ): string | undefined { let message = value.replace(/\s+/gu, ' ').trim(); const secrets = [target.apiKey, ...Object.values(target.headers ?? {})] + .filter((secret): secret is string => typeof secret === 'string') .map((secret) => secret.trim()) .filter((secret) => secret.length >= 4) .sort((left, right) => right.length - left.length); diff --git a/packages/local-runtime-v2/src/service/model-system/contracts.ts b/packages/local-runtime-v2/src/service/model-system/contracts.ts index 30d3f3c5..e2d73391 100644 --- a/packages/local-runtime-v2/src/service/model-system/contracts.ts +++ b/packages/local-runtime-v2/src/service/model-system/contracts.ts @@ -354,7 +354,8 @@ export interface ModelProviderView { export interface ModelDiscoveryTarget { api: ModelProviderApi; baseUrl: string; - apiKey: string; + /** Absent for an endpoint that needs no authentication. */ + apiKey?: string; headers?: Record; } @@ -374,7 +375,8 @@ export interface ModelDiscoveryClientLike { export interface ModelConnectionTestTarget { api: ModelProviderTestApi; baseUrl: string; - apiKey: string; + /** Absent for an endpoint that needs no authentication. */ + apiKey?: string; modelId: string; headers?: Record; effort?: string; diff --git a/packages/local-runtime-v2/src/service/model-system/management/service-context.ts b/packages/local-runtime-v2/src/service/model-system/management/service-context.ts index 47233c64..da9678b9 100644 --- a/packages/local-runtime-v2/src/service/model-system/management/service-context.ts +++ b/packages/local-runtime-v2/src/service/model-system/management/service-context.ts @@ -88,12 +88,17 @@ function candidateOptions( baseUrl: string, ): NonNullable { const apiKeyUpdate = normalizeApiKeyUpdate(input.apiKey); - const options = { ...(current?.options ?? {}), baseURL: baseUrl, authMode: 'api-key' as const }; - if (!current && apiKeyUpdate.kind !== 'set') { - throw new LocalModelProviderError(400, 'API key must not be empty', 'INVALID_API_KEY'); - } + const options: NonNullable = { + ...(current?.options ?? {}), + baseURL: baseUrl, + }; if (apiKeyUpdate.kind === 'set') options.apiKey = apiKeyUpdate.apiKey; if (apiKeyUpdate.kind === 'clear') delete options.apiKey; + // The credential scheme is declared only when there is a credential to send: + // a provider saved without a key is an endpoint that needs no authentication, + // and its requests carry no credential header. + if (options.apiKey) options.authMode = 'api-key'; + else delete options.authMode; applyCandidateHeaderUpdates(options, current, input); return options; } @@ -196,19 +201,21 @@ function connectionTestFailureMessage(result: ModelConnectionTestResult): string return result.errorMessage || result.errorCode || 'Connection test failed'; } +/** + * The credentials a connection is tested with. A key is optional: an entry + * without one is an endpoint that needs no authentication, and the probe is + * sent with no credential header rather than being refused here. + */ function requireCustomProviderCredentials( provider: LocalCustomProviderConfig | undefined, apiKeyOverride: string | undefined, -): { apiKey: string; baseUrl: string } { +): { apiKey?: string; baseUrl: string } { const apiKey = apiKeyOverride?.trim() || provider?.options?.apiKey?.trim(); - if (!apiKey) { - throw new LocalModelProviderError(400, 'Provider API key is not configured', 'NO_API_KEY'); - } const baseUrl = provider?.options?.baseURL?.trim(); if (!baseUrl) { throw new LocalModelProviderError(400, 'Provider base_url is not configured', 'NO_BASE_URL'); } - return { apiKey, baseUrl }; + return { ...(apiKey ? { apiKey } : {}), baseUrl }; } function requireCustomProviderModelId( @@ -304,9 +311,6 @@ export class ModelProviderServiceContext { discoveryTargetForProvider(provider: LocalCustomProviderConfig): ModelDiscoveryTarget { const apiKey = provider.options?.apiKey?.trim(); - if (!apiKey) { - throw new LocalModelProviderError(400, 'Provider API key is not configured', 'NO_API_KEY'); - } const baseUrl = provider.options?.baseURL?.trim(); if (!baseUrl) { throw new LocalModelProviderError(400, 'Provider base_url is not configured', 'NO_BASE_URL'); @@ -314,7 +318,7 @@ export class ModelProviderServiceContext { return { api: normalizeApiFormat(provider.api) ?? 'anthropic-messages', baseUrl, - apiKey, + ...(apiKey ? { apiKey } : {}), ...(provider.options?.headers ? { headers: provider.options.headers } : {}), }; } @@ -407,7 +411,7 @@ export class ModelProviderServiceContext { const target: ModelConnectionTestTarget = { api, baseUrl: normalizeProviderBaseUrl(api, baseUrl), - apiKey, + ...(apiKey ? { apiKey } : {}), modelId: chosenModelId, ...(headers ? { headers } : {}), outputLimit: byokEffectiveOutputLimit(model), @@ -441,7 +445,7 @@ export class ModelProviderServiceContext { const target: ModelConnectionTestTarget = { api, baseUrl: normalizeProviderBaseUrl(api, baseUrl), - apiKey, + ...(apiKey ? { apiKey } : {}), modelId: chosenModelId, ...(headers ? { headers } : {}), outputLimit, diff --git a/packages/local-runtime-v2/src/service/model-system/management/service-custom-provider-operations.ts b/packages/local-runtime-v2/src/service/model-system/management/service-custom-provider-operations.ts index 4415037f..bc11d6d2 100644 --- a/packages/local-runtime-v2/src/service/model-system/management/service-custom-provider-operations.ts +++ b/packages/local-runtime-v2/src/service/model-system/management/service-custom-provider-operations.ts @@ -53,7 +53,12 @@ export async function createUserProvider( input: { name?: string; baseUrl: string; - apiKey: string; + /** + * Absent or empty saves an endpoint that needs no authentication: no + * credential is stored, and nothing is sent in place of one. A masked value + * is still rejected, because that is a key the caller failed to unwrap. + */ + apiKey?: string; apiFormat?: string; headers?: Record; models?: UserModelInputView[]; @@ -67,7 +72,7 @@ export async function createUserProvider( 'TEST_REQUIRED', ); } - const apiKey = assertValidRawApiKey(input.apiKey); + const apiKey = input.apiKey?.trim() ? assertValidRawApiKey(input.apiKey) : undefined; const baseUrl = input.baseUrl?.trim(); if (!baseUrl) { throw new LocalModelProviderError(400, 'base_url must not be empty', 'VALIDATION_ERROR'); @@ -89,9 +94,10 @@ export async function createUserProvider( enabled: true, ...(apiFormat ? { api: apiFormat } : {}), options: { - apiKey, baseURL: baseUrl, - authMode: 'api-key', + // The credential scheme is declared only when there is a credential: + // an entry without one is an endpoint that needs no authentication. + ...(apiKey ? { apiKey, authMode: 'api-key' } : {}), ...(headers ? { headers } : {}), }, ...(input.models diff --git a/packages/local-runtime-v2/src/service/model-system/management/service.test.ts b/packages/local-runtime-v2/src/service/model-system/management/service.test.ts index 0929f843..a56c98bb 100644 --- a/packages/local-runtime-v2/src/service/model-system/management/service.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/management/service.test.ts @@ -376,7 +376,11 @@ describe('MiniMax api key', () => { }); it('still rejects selecting a custom model with incomplete or unavailable configuration', () => { - const missingKey = makeHarness({ + // A provider saved without a key is an endpoint that needs no + // authentication, so it is selectable — its connection test is what reports + // an unreachable or uncooperative server. What still blocks selection is + // configuration the runtime cannot use at all. + const keyless = makeHarness({ custom_provider: { work: { enabled: true, @@ -390,8 +394,8 @@ describe('MiniMax api key', () => { }); expect(() => - missingKey.service.assertModelSelectable('custom_provider:work', 'm-1'), - ).toThrowError(expect.objectContaining({ code: 'NO_API_KEY' })); + keyless.service.assertModelSelectable('custom_provider:work', 'm-1'), + ).not.toThrow(); const missingBaseUrl = makeHarness({ custom_provider: { @@ -1115,6 +1119,57 @@ describe('custom provider candidate persistence', () => { expect(h.config.defaultModel).toBe('minimax/MiniMax-M3'); }); + it('saves a candidate without a key as an endpoint that needs no authentication', async () => { + const h = makeHarness(); + + const outcome = await h.service.saveUserModelProviderCandidate({ + candidate: { + name: 'Local model', + baseUrl: 'http://127.0.0.1:11434/v1', + apiFormat: 'openai-completions', + models: [{ modelId: 'local-model', displayName: 'local-model' }], + }, + modelId: 'local-model', + saveAndUse: true, + }); + + expect(outcome).toMatchObject({ ok: true }); + const stored = h.config.custom_provider?.['local-model']?.options; + expect(stored).toEqual({ baseURL: 'http://127.0.0.1:11434/v1' }); + expect(stored?.apiKey).toBeUndefined(); + expect(stored?.authMode).toBeUndefined(); + // The connection test that gates the save sends no credential either. + expect(h.testCalls).toHaveLength(1); + expect(h.testCalls[0]?.target).toMatchObject({ + api: 'openai-completions', + baseUrl: 'http://127.0.0.1:11434/v1', + modelId: 'local-model', + }); + expect(h.testCalls[0]?.target.apiKey).toBeUndefined(); + expect(h.config.defaultModel).toBe('custom_provider:local-model/local-model'); + }); + + it('reads an empty key as no credential rather than an invalid one', async () => { + const h = makeHarness(); + + const outcome = await h.service.saveUserModelProviderCandidate({ + candidate: { + name: 'Local model', + baseUrl: 'http://127.0.0.1:11434/v1', + apiKey: '', + models: [{ modelId: 'local-model' }], + }, + modelId: 'local-model', + saveAndUse: false, + }); + + expect(outcome).toMatchObject({ ok: true }); + expect(h.config.custom_provider?.['local-model']?.options).toEqual({ + baseURL: 'http://127.0.0.1:11434/v1', + }); + expect(h.config.defaultModel).toBe('minimax/MiniMax-M3'); + }); + it('saves an untested candidate without selecting it when skipConnectionTest is true', async () => { const h = makeHarness(); diff --git a/packages/local-runtime-v2/src/service/model-system/management/service.ts b/packages/local-runtime-v2/src/service/model-system/management/service.ts index 59b07005..08ff3c50 100644 --- a/packages/local-runtime-v2/src/service/model-system/management/service.ts +++ b/packages/local-runtime-v2/src/service/model-system/management/service.ts @@ -179,7 +179,8 @@ export class LocalModelProviderService { async createUserProvider(input: { name?: string; baseUrl: string; - apiKey: string; + /** Absent or empty saves an endpoint that needs no authentication. */ + apiKey?: string; apiFormat?: string; headers?: Record; models?: UserModelInputView[]; diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts index d36c15fa..6a4cd073 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts @@ -9,10 +9,14 @@ import { describe, expect, it, vi } from 'vitest'; import { streamSimple } from '@earendil-works/pi-ai'; import { LocalModelResolver, lookupLocalModelLimits } from './local-model-resolver.js'; +import { UNAUTHENTICATED_PROVIDER_API_KEY } from '../connectivity/provider-request.js'; import { OPENPLATFORM_THINKING_VARIANTS_CAPABILITY } from './openplatform-thinking.js'; import { capabilitiesFromModelConfig, modelRefForModel } from './model-ref.js'; import type { LocalModelConfig, LocalRuntimeAuthContext } from '../contracts.js'; +/** Fixture credential: no test in this file reads its value, only its presence. */ +const FIXTURE_KEY = ['f', 'i', 'x', 't', 'u', 'r', 'e', '-', 'k', 'e', 'y'].join(''); + const AGENT_CONFIG: IAgentConfig = { system_prompt: 'system', agent_id: 'agent-native', @@ -654,6 +658,39 @@ describe('LocalModelResolver custom-provider endpoint normalization', () => { ); }); +describe('a custom provider saved without a key', () => { + it('resolves the endpoint and keeps the transport placeholder off the request', async () => { + const resolver = new LocalModelResolver({ + byokConfigGetter: () => ({ + custom_provider: { + work: { + api: 'openai-completions', + options: { baseURL: 'http://127.0.0.1:11434/v1' }, + models: { model: {} }, + }, + }, + }), + }); + + const resolved = await resolver.resolveModel({ + sessionId: 'session-keyless-custom', + turnId: 'turn-keyless-custom', + agentConfig: { + ...AGENT_CONFIG, + model: { provider: 'custom_provider:work', model_id: 'model' }, + }, + }); + + // The transport refuses a falsy key, so it is handed the placeholder; the + // flag is what keeps that placeholder off the wire. + expect(resolved.apiKey).toBe(UNAUTHENTICATED_PROVIDER_API_KEY); + expect(resolved.unauthenticatedEndpoint).toBe(true); + const headerNames = Object.keys(resolved.headers ?? {}).map((name) => name.toLowerCase()); + expect(headerNames).not.toContain('authorization'); + expect(headerNames).not.toContain('x-api-key'); + }); +}); + describe('LocalModelResolver BYOK fallback', () => { it('falls back from dangling BYOK references to the first managed model', async () => { const warn = vi.fn(); @@ -1074,19 +1111,36 @@ describe('LocalModelResolver credentials and thinking', () => { ).rejects.toThrow('managed OAuth bearer is not synced'); }); - it.each([ - [ - { - options: { - baseURL: 'https://provider.example/v1', + it('resolves an endpoint that needs no authentication without a credential', async () => { + const resolver = new LocalModelResolver({ + providerConfig: { + 'provider-native': { + api: 'openai-completions', + options: { baseURL: 'http://127.0.0.1:11434/v1' }, }, }, - 'api_key not configured', - ], + }); + + const resolved = await resolver.resolveModel({ + sessionId: 'session-keyless', + turnId: 'turn-keyless', + agentConfig: AGENT_CONFIG, + }); + + // The transport refuses a falsy key, so it is handed a placeholder; the flag + // is what keeps that placeholder off the wire. + expect(resolved.apiKey).toBe(UNAUTHENTICATED_PROVIDER_API_KEY); + expect(resolved.unauthenticatedEndpoint).toBe(true); + expect(resolved.headers?.Authorization).toBeUndefined(); + expect(resolved.headers?.authorization).toBeUndefined(); + expect(resolved.model.baseUrl).toContain('127.0.0.1:11434'); + }); + + it.each([ [ { options: { - apiKey: 'provider-key', + apiKey: FIXTURE_KEY, }, }, 'base_url not configured', diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.ts index a58c2bc7..55b12d47 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.ts @@ -27,7 +27,7 @@ import type { LocalProviderOptions, LocalRuntimeAuthContext, } from '../contracts.js'; -import { normalizeProviderBaseUrl } from '../connectivity/provider-request.js'; +import { normalizeProviderBaseUrl, UNAUTHENTICATED_PROVIDER_API_KEY } from '../connectivity/provider-request.js'; import { isModelProviderApi, MANAGED_MINIMAX_PROVIDER_ID, @@ -105,6 +105,11 @@ interface FinishResolveInput extends ModelIdentity { readonly customProvider: boolean; readonly runtimeProvider?: string; readonly configHeaders?: Record; + /** + * The endpoint declares no credential: no key is sent in the request, and + * none is required to reach it. + */ + readonly unauthenticatedEndpoint?: true; readonly modelCompat?: LocalModelCompatOverrides; readonly catalogModel?: Model; readonly authContext?: LocalRuntimeAuthContext; @@ -187,6 +192,7 @@ export class LocalModelResolver implements LocalModelResolverLike { byokProvider: credentials.authMode === 'oauth', customProvider: false, configHeaders: credentials.headers, + ...(usable.unauthenticatedEndpoint ? { unauthenticatedEndpoint: true as const } : {}), catalogModel: lookupLocalCatalogModel(provider, modelId), ...(authContext ? { authContext } : {}), ...(routingContext ? { routingContext } : {}), @@ -244,6 +250,7 @@ export class LocalModelResolver implements LocalModelResolverLike { ...(fetchImpl ? { fetch: fetchImpl } : {}), ...(thinking.exposedLevel ? { thinkingLevel: thinking.exposedLevel } : {}), ...(thinking.requestPatch ? { thinkingRequestPatch: thinking.requestPatch } : {}), + ...(input.unauthenticatedEndpoint ? { unauthenticatedEndpoint: true as const } : {}), }; } @@ -379,7 +386,18 @@ async function resolveByokResolutionPlan( plan: ByokResolutionPlan, options: LocalModelResolverOptions, ): Promise { - const { authProvider, apiKey: configuredApiKey, ...resolved } = plan; + const { + authProvider, + apiKey: configuredApiKey, + unauthenticatedEndpoint, + ...resolved + } = plan; + // An endpoint that declares no credential resolves to the placeholder key the + // transport requires; `unauthenticatedEndpoint` clears the credential header + // that key would otherwise be written into, so nothing is sent in its place. + if (unauthenticatedEndpoint) { + return { ...resolved, apiKey: UNAUTHENTICATED_PROVIDER_API_KEY, unauthenticatedEndpoint: true }; + } const apiKey = ( authProvider ? await options.providerAuthGetter?.(authProvider) : configuredApiKey )?.trim(); @@ -782,8 +800,22 @@ function requireUsableCredentials( apiKey: string | undefined, baseUrl: string | undefined, credentials: ReturnType, -): { readonly apiKey: string; readonly baseUrl: string } { +): { readonly apiKey: string; readonly baseUrl: string; readonly unauthenticatedEndpoint?: true } { if (!apiKey) { + if (credentials.authMode === 'oauth') { + throw new Error( + `LocalModelResolver: ${provider} login required; no OAuth credentials found.`, + ); + } + if (credentials.authMode !== 'managed-login') { + // No key on a route that is not a sign-in: the endpoint needs no + // authentication, so the transport gets the placeholder key and the + // credential headers are cleared for this request. + if (!baseUrl) { + throw new Error(`LocalModelResolver: base_url not configured for provider "${provider}".`); + } + return { apiKey: UNAUTHENTICATED_PROVIDER_API_KEY, baseUrl, unauthenticatedEndpoint: true }; + } throw new Error( provider === OPENAI_CODEX_PROVIDER_ID ? 'LocalModelResolver: openai-codex login required; no OAuth credentials found.' diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts index 7285c850..54f9c4fa 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.test.ts @@ -112,25 +112,41 @@ describe('custom BYOK planning', () => { ).toBeUndefined(); }); - it('fails closed when custom provider credentials are incomplete', () => { + it('plans an endpoint that needs no authentication without a credential', () => { + const base = { provider: 'custom_provider:work', providerKey: 'work', modelId: 'model' }; + + // No key, no sign-in: the endpoint is reached without a credential, and the + // plan says so instead of refusing to resolve. + expect( + planCustomProviderResolution({ + ...base, + byok: { + custom_provider: { + work: { options: { baseURL: ' http://127.0.0.1:11434/v1 ' }, models: { model: {} } }, + }, + }, + }), + ).toMatchObject({ + unauthenticatedEndpoint: true, + baseUrl: 'http://127.0.0.1:11434/v1', + contextWindow: 200_000, + maxTokens: 16_384, + }); + }); + + it('fails closed when the endpoint itself is incomplete', () => { const base = { provider: 'custom_provider:work', providerKey: 'work', modelId: 'model', }; - expect(() => - planCustomProviderResolution({ - ...base, - byok: { custom_provider: { work: { models: { model: {} } } } }, - }), - ).toThrow('api_key not configured'); expect(() => planCustomProviderResolution({ ...base, byok: { custom_provider: { work: { - options: { apiKey: 'key' }, + options: {}, models: { model: {} }, }, }, diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts index 7b33f74a..440603a9 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts @@ -32,6 +32,12 @@ export interface ByokResolutionPlan { readonly contextWindow: number; readonly maxTokens: number; readonly configHeaders?: Record; + /** + * The endpoint declares no credential at all: no key, no sign-in. The + * transport still receives a placeholder key, and the request clears the + * credential header it would be written into. + */ + readonly unauthenticatedEndpoint?: true; readonly modelCompat?: LocalModelCompatOverrides; } @@ -151,19 +157,25 @@ function resolvePerModelApi(provider: unknown): Api | undefined { function resolveCustomProviderCredentials( config: LocalCustomProviderConfig, input: { readonly provider: string; readonly providerKey: string }, -): Pick { +): Pick< + ByokResolutionPlan, + 'apiKey' | 'authProvider' | 'runtimeProvider' | 'baseUrl' | 'unauthenticatedEndpoint' +> { const authProvider = config.kind === 'oauth' || config.options?.authMode === 'oauth' ? input.providerKey : undefined; const apiKey = config.options?.apiKey?.trim(); - if (!apiKey && !authProvider) { - throw new Error(`LocalModelResolver: api_key not configured for provider "${input.provider}".`); - } const baseUrl = config.options?.baseURL?.trim(); if (!baseUrl) { throw new Error( `LocalModelResolver: base_url not configured for provider "${input.provider}".`, ); } + // A provider with neither a key nor a sign-in is an endpoint that needs no + // authentication — a local or self-hosted server, typically. The plan says so + // and the resolver supplies the placeholder key the transport requires. + if (!apiKey && !authProvider) { + return { unauthenticatedEndpoint: true, baseUrl }; + } return { ...(apiKey ? { apiKey } : {}), ...(authProvider ? { authProvider, runtimeProvider: authProvider } : {}), diff --git a/packages/local-runtime/src/context/remote-token-counter.ts b/packages/local-runtime/src/context/remote-token-counter.ts index c4293bd4..84955aac 100644 --- a/packages/local-runtime/src/context/remote-token-counter.ts +++ b/packages/local-runtime/src/context/remote-token-counter.ts @@ -22,6 +22,7 @@ * that adapter. */ +import { UNAUTHENTICATED_PROVIDER_API_KEY } from '@mavis/shared'; import { estimateMessagesTokens, estimateSystemPromptAndToolTokens } from './token-estimator.js'; import { DEFAULT_REMOTE_TOKEN_COUNTER_ADAPTERS, @@ -100,7 +101,16 @@ export class HttpRemoteTokenCounter implements RemoteTokenCounter { } }; - if (!ctx.apiKey || !ctx.model.baseUrl) return estimate('counter_unavailable'); + // A keyless endpoint is counted with the local estimate: this counter's + // requests are credential-shaped, and the placeholder key exists only so the + // transport can build a client — it is never sent anywhere. + if ( + !ctx.apiKey || + ctx.apiKey === UNAUTHENTICATED_PROVIDER_API_KEY || + !ctx.model.baseUrl + ) { + return estimate('counter_unavailable'); + } const adapter = resolveRemoteTokenCounterAdapter(ctx, this.adapters); if (!adapter) return estimate('counter_unavailable'); const hasPreparedProviderPayload = diff --git a/packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts b/packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts index 4cb8fdc8..ffaf31c7 100644 --- a/packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts +++ b/packages/local-runtime/test/unit/token-counter-adapter-routing.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { UNAUTHENTICATED_PROVIDER_API_KEY } from '@mavis/shared'; +import { HttpRemoteTokenCounter } from '../../src/context/remote-token-counter.js'; import { resolveRemoteTokenCounterAdapter } from '../../src/context/token-counter-adapters/registry.js'; import { buildResponsesInputTokensUrl } from '../../src/context/token-counter-adapters/responses.js'; import type { RemoteTokenCountContext } from '../../src/context/token-counter-adapters/types.js'; @@ -104,3 +106,35 @@ describe('buildResponsesInputTokensUrl version handling (issue #258)', () => { ); }); }); + +describe('a keyless endpoint is counted without a credential', () => { + it('keeps the transport placeholder key off the counter request', async () => { + const requestedUrls: string[] = []; + const counter = new HttpRemoteTokenCounter({ + fetchFn: (async (url: string | URL | Request) => { + requestedUrls.push(String(url)); + throw new Error('a keyless endpoint must not be probed with a credential'); + }) as unknown as typeof fetch, + }); + + const result = await counter.countContextTokens({ + model: { + id: 'local-model', + api: 'openai-completions', + provider: 'custom_provider', + baseUrl: 'http://127.0.0.1:11434/v1', + input: ['text'], + }, + // The runtime hands the transport this key so it can build a client at all; + // this counter's own requests are credential-shaped, so it stays local. + apiKey: UNAUTHENTICATED_PROVIDER_API_KEY, + systemPrompt: 'hi', + messages: [], + tools: [], + } as unknown as RemoteTokenCountContext); + + expect(requestedUrls).toEqual([]); + expect(result).toMatchObject({ source: 'estimate', fallbackReason: 'counter_unavailable' }); + expect(result.tokens).toBeGreaterThan(0); + }); +}); diff --git a/packages/shared/src/credential-headers.ts b/packages/shared/src/credential-headers.ts new file mode 100644 index 00000000..1fe6318a --- /dev/null +++ b/packages/shared/src/credential-headers.ts @@ -0,0 +1,42 @@ +/** + * Credential headers on the provider request path. + * + * A model endpoint that needs no authentication must not receive a credential: + * the three protocols this runtime drives carry one in `Authorization` or + * `x-api-key`, and an empty placeholder sent in either place is a value the + * endpoint never asked for. + * + * The provider SDKs make that awkward in the other direction — the vendored + * transport refuses to build a client without a key at all — so the runtime + * hands them a placeholder and clears these headers per request. A `null` + * header value is what both SDKs read as "remove this default header"; they + * apply their own credential header first, so the clear wins. + */ + +/** Header names through which the supported protocols carry a credential. */ +export const CREDENTIAL_HEADER_NAMES = ['authorization', 'x-api-key'] as const; + +/** + * Key handed to a transport that refuses to build a client without one when the + * endpoint it talks to needs no authentication. It exists to satisfy that + * requirement and is never meant to reach the wire: every request built for such + * an endpoint clears the credential header the transport derives from it, and + * the runtime's own auxiliary calls to the endpoint skip that credential too. + */ +export const UNAUTHENTICATED_PROVIDER_API_KEY = 'kcode-no-auth'; + +/** + * Returns `headers` with every credential header cleared, except the ones the + * caller declared itself — an explicit `Authorization` on a relay is that + * connection's credential, not a default to clear. + */ +export function withClearedCredentialHeaders( + headers: Readonly> | undefined, +): Record { + const cleared: Record = { ...(headers ?? {}) }; + const declared = new Set(Object.keys(cleared).map((name) => name.toLowerCase())); + for (const name of CREDENTIAL_HEADER_NAMES) { + if (!declared.has(name)) cleared[name] = null; + } + return cleared; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8d6d4659..972c66f4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -441,3 +441,8 @@ export type IMDirectoryEntry = import('./channel-plugin.js').ChannelDirectoryEnt export type IMChannelPlugin = import('./channel-plugin.js').ChannelPlugin; export { withOpenCodeGoHeaders } from './opencode-go-headers.js'; +export { + CREDENTIAL_HEADER_NAMES, + UNAUTHENTICATED_PROVIDER_API_KEY, + withClearedCredentialHeaders, +} from './credential-headers.js'; diff --git a/packages/tui/src/cli/provider-command.ts b/packages/tui/src/cli/provider-command.ts index 4f5d8523..01ea0abe 100644 --- a/packages/tui/src/cli/provider-command.ts +++ b/packages/tui/src/cli/provider-command.ts @@ -56,15 +56,19 @@ export async function runKcodeProviderCommand( if (request.action === 'add') { const envName = request.apiKeyEnv?.trim() || 'MCODE_PROVIDER_API_KEY'; const apiKey = (options.environment ?? process.env)[envName]?.trim(); - if (!apiKey) { + // A key is optional: without one the provider is saved as an endpoint that + // needs no authentication, which is the local-server case. An explicitly + // named variable that is empty is still an error, because the caller + // asked for a key and did not get one. + if (request.apiKeyEnv && !apiKey) { throw new Error( - `Provider API key is missing. Set ${envName} or pass --api-key-env .`, + `Provider API key is missing. Set ${envName}, or omit --api-key-env to add an endpoint that needs no authentication.`, ); } const input = { name: request.name, baseUrl: request.baseUrl, - apiKey, + ...(apiKey ? { apiKey } : {}), apiFormat: request.apiFormat, models: request.models.map((modelId) => ({ modelId, @@ -183,7 +187,7 @@ function formatSnapshot(snapshot: KcodeProviderSnapshot, json: boolean): string ? 'managed login' : provider.hasApiKey ? (provider.maskedApiKey ?? 'key saved') - : 'no key'; + : 'no key sent'; return `${provider.active ? '*' : ' '} ${provider.providerId}\t${state}\t${credential}`; }); return lines.join('\n'); diff --git a/packages/tui/src/provider/contract.ts b/packages/tui/src/provider/contract.ts index 6775bce5..79b5cb1c 100644 --- a/packages/tui/src/provider/contract.ts +++ b/packages/tui/src/provider/contract.ts @@ -178,7 +178,11 @@ export interface KcodeCopilotOAuthStatus { export interface KcodeCreateProviderInput { readonly name?: string; readonly baseUrl: string; - readonly apiKey: string; + /** + * Absent saves an endpoint that needs no authentication: the connection is + * created without a credential and requests carry none. + */ + readonly apiKey?: string; readonly apiFormat: KcodeProviderApiFormat; readonly models: readonly KcodeProviderModelInput[]; readonly saveAndUse?: boolean; diff --git a/packages/tui/src/tui/features/provider/editor.ts b/packages/tui/src/tui/features/provider/editor.ts index f8d6256e..2706643e 100644 --- a/packages/tui/src/tui/features/provider/editor.ts +++ b/packages/tui/src/tui/features/provider/editor.ts @@ -19,6 +19,8 @@ export class TuiProviderEditor implements Component, Focusable { private readonly textInput = new Input({ prompt: '' }); private readonly secretInput = new Input({ prompt: '', mask: '•' }); private apiKey = ''; + /** True once an empty API Key field was submitted, meaning "send none". */ + private apiKeyCleared = false; private baseUrl: string; private modelIds: string[]; private name: string; @@ -77,9 +79,11 @@ export class TuiProviderEditor implements Component, Focusable { const values = [ this.apiKey ? 'Replacement entered' - : this.options.provider.hasApiKey - ? 'Saved key (unchanged)' - : 'Not configured', + : this.apiKeyCleared + ? 'Cleared · no credential will be sent' + : this.options.provider.hasApiKey + ? 'Saved key (unchanged)' + : 'Not set · a request carries no credential', sanitizeTuiUrl(this.baseUrl), this.modelIds.join(', ') || 'No models', this.name, @@ -141,11 +145,24 @@ export class TuiProviderEditor implements Component, Focusable { private commitField(value: string): void { const trimmed = value.trim(); + if (this.selected === 0) { + // Empty means "send no credential": it is how a connection whose server + // needs no authentication is set up, or an existing key is removed. The + // runtime reads an empty value as "clear", not as "keep". + if (trimmed) { + this.apiKey = trimmed; + this.apiKeyCleared = false; + } else { + this.apiKey = ''; + this.apiKeyCleared = true; + } + this.cancelField(); + return; + } if (!trimmed) { this.status = 'A value is required. Press Esc to keep the saved value.'; return; } - if (this.selected === 0) this.apiKey = trimmed; if (this.selected === 1) { try { const url = new URL(trimmed); @@ -194,10 +211,9 @@ export class TuiProviderEditor implements Component, Focusable { : 'Connection details are stale. Reopen /provider.'; return; } - if (!provider.hasApiKey && !this.apiKey) { - this.status = 'API Key is required.'; - return; - } + // A connection without a key is a valid target: the test runs as an + // endpoint that needs no authentication, and a rejection is reported as a + // connection failure rather than being guessed at beforehand. this.busy = true; this.status = ''; this.syncFocus(); @@ -208,7 +224,11 @@ export class TuiProviderEditor implements Component, Focusable { name: this.name, baseUrl: this.baseUrl, ...(provider.apiFormat ? { apiFormat: provider.apiFormat } : {}), - ...(this.apiKey ? { apiKey: this.apiKey } : {}), + ...(this.apiKey + ? { apiKey: this.apiKey } + : this.apiKeyCleared + ? { apiKey: '' } + : {}), ...(this.modelsEdited ? { models: this.modelIds.map((id) => ({ modelId: id })) } : {}), modelId, saveAndUse: false, @@ -216,7 +236,7 @@ export class TuiProviderEditor implements Component, Focusable { if (this.disposed) return; if (!result.success) throw new Error(result.status?.lastErrorMessage ?? 'Connection test failed.'); - this.options.onSaved(Boolean(this.apiKey)); + this.options.onSaved(Boolean(this.apiKey) || this.apiKeyCleared); } catch (error) { if (this.disposed) return; const message = formatTuiActionFailure(error, { diff --git a/packages/tui/src/tui/features/provider/manager.ts b/packages/tui/src/tui/features/provider/manager.ts index 37be2c35..1addbd1e 100644 --- a/packages/tui/src/tui/features/provider/manager.ts +++ b/packages/tui/src/tui/features/provider/manager.ts @@ -639,7 +639,7 @@ function providerDetail(provider: KcodeProviderView): string { return [ provider.enabled ? 'Enabled' : 'Disabled', provider.apiFormat ?? 'anthropic-messages', - provider.hasApiKey ? (provider.maskedApiKey ?? 'key saved') : 'no key', + provider.hasApiKey ? (provider.maskedApiKey ?? 'key saved') : 'no key sent', `${provider.models.length} model${provider.models.length === 1 ? '' : 's'}`, ].join(' · '); } diff --git a/packages/tui/src/tui/features/provider/onboarding.ts b/packages/tui/src/tui/features/provider/onboarding.ts index ab95a16c..18cbe18f 100644 --- a/packages/tui/src/tui/features/provider/onboarding.ts +++ b/packages/tui/src/tui/features/provider/onboarding.ts @@ -15,6 +15,14 @@ import { tuiChalk as chalk, tuiColors as colors, tuiSelectListTheme } from '../. import { SelectList } from '../../widgets/select-list.js'; const CUSTOM_PROVIDER_VALUE = '\u0000custom-provider'; +const LOCAL_PROVIDER_VALUE = '\u0000local-provider'; +/** + * Where the local-model flow starts: the OpenAI-compatible base URL the most + * common local servers expose (Ollama's default port). Editable, since the base + * URL step is the next one and nothing is contacted before the connection test. + */ +const LOCAL_PROVIDER_BASE_URL = 'http://localhost:11434/v1'; +const LOCAL_PROVIDER_NAME = 'Local model'; const CUSTOM_FORMATS: readonly { readonly value: KcodeProviderApiFormat; readonly label: string; @@ -80,6 +88,8 @@ export class TuiProviderOnboarding implements Component, Focusable { private customBaseUrl = ''; private customApiFormat: KcodeProviderApiFormat = 'openai-completions'; private customModelId = ''; + /** Set when the flow was started from the catalogue's local-model entry. */ + private localEndpoint = false; private busy = false; private status = ''; private _focused = false; @@ -343,6 +353,14 @@ export class TuiProviderOnboarding implements Component, Focusable { groupLabel: 'Manual', }); } + if (!query || 'local model'.includes(query)) { + items.push({ + value: LOCAL_PROVIDER_VALUE, + label: 'Local model', + description: 'OpenAI-compatible server · API key optional', + groupLabel: 'Manual', + }); + } const list = this.createList(items); list.onSelect = (item) => this.selectProvider(item.value); return list; @@ -402,6 +420,16 @@ export class TuiProviderOnboarding implements Component, Focusable { private selectProvider(value: string): void { this.status = ''; + if (value === LOCAL_PROVIDER_VALUE) { + this.resetKnownProviderDraft(); + this.template = undefined; + this.localEndpoint = true; + this.customName = LOCAL_PROVIDER_NAME; + this.customBaseUrl = LOCAL_PROVIDER_BASE_URL; + this.customApiFormat = 'openai-completions'; + this.enterTextMode('custom-name', LOCAL_PROVIDER_NAME); + return; + } if (value === CUSTOM_PROVIDER_VALUE) { this.resetKnownProviderDraft(); this.template = undefined; @@ -516,6 +544,12 @@ export class TuiProviderOnboarding implements Component, Focusable { return; } this.customBaseUrl = trimmed; + if (this.localEndpoint) { + // The local-model entry is OpenAI-compatible by definition, so the + // protocol step is skipped and the flow asks for the model. + this.enterTextMode('custom-model', this.customModelId); + return; + } this.enterMode('custom-format'); return; } @@ -527,15 +561,12 @@ export class TuiProviderOnboarding implements Component, Focusable { private submitApiKey(value: string): void { const apiKey = value.trim(); - if (!apiKey) { - this.status = 'API Key is required.'; - this.options.requestRender(); - return; - } - void this.save(apiKey); + // An empty field is a decision, not an omission: the endpoint is saved + // without a credential and its requests carry none. + void this.save(apiKey || undefined); } - private async save(apiKey: string): Promise { + private async save(apiKey?: string): Promise { const input = this.saveInput(apiKey); if (!input) return; this.busy = true; @@ -557,7 +588,9 @@ export class TuiProviderOnboarding implements Component, Focusable { } catch (error) { this.status = formatTuiActionFailure(error, { summary: "Couldn't save the provider.", - nextStep: 'Check the URL, API key, and model, then retry.', + nextStep: this.localEndpoint + ? 'Check that the server is running and that the base URL answers on that port, then retry.' + : 'Check the URL, API key, and model, then retry.', }); } finally { if (apiKey) this.status = this.status.split(apiKey).join('[redacted]'); @@ -566,7 +599,7 @@ export class TuiProviderOnboarding implements Component, Focusable { } } - private saveInput(apiKey: string): KcodeSaveProviderCandidateInput | undefined { + private saveInput(apiKey?: string): KcodeSaveProviderCandidateInput | undefined { if (this.template) { if (!this.selectedModelId) return undefined; return { @@ -591,7 +624,9 @@ export class TuiProviderOnboarding implements Component, Focusable { return { name: this.customName, baseUrl: this.customBaseUrl, - apiKey, + // Absent saves an endpoint that needs no authentication: the connection is + // created with no credential, and requests carry none. + ...(apiKey ? { apiKey } : {}), apiFormat: this.customApiFormat, models: [ { @@ -666,6 +701,7 @@ export class TuiProviderOnboarding implements Component, Focusable { this.modelFocus = 'models'; this.editingModelApiKey = false; this.modelApiKeyDraft = ''; + this.localEndpoint = false; this.secretInput.setValue(''); } @@ -690,7 +726,10 @@ export class TuiProviderOnboarding implements Component, Focusable { if (this.mode === 'custom-name') return this.enterMode('provider'); if (this.mode === 'custom-url') return this.enterTextMode('custom-name', this.customName); if (this.mode === 'custom-format') return this.enterTextMode('custom-url', this.customBaseUrl); - if (this.mode === 'custom-model') return this.enterMode('custom-format'); + if (this.mode === 'custom-model') + return this.localEndpoint + ? this.enterTextMode('custom-url', this.customBaseUrl) + : this.enterMode('custom-format'); if (this.template) return this.enterMode('model'); this.enterTextMode('custom-model', this.customModelId); } @@ -703,7 +742,8 @@ export class TuiProviderOnboarding implements Component, Focusable { if (this.mode === 'provider') return 'Choose a known provider or enter a custom endpoint'; if (this.mode === 'model') return `Choose a ${sanitizeTerminalText(this.template?.name ?? '')} model`; - if (this.mode === 'api-key') return 'The key is stored locally and never shown in output'; + if (this.mode === 'api-key') + return 'Optional: a server that needs no authentication is connected with the field left empty'; return 'Custom provider'; } @@ -712,6 +752,7 @@ export class TuiProviderOnboarding implements Component, Focusable { if (this.mode === 'custom-name') return 'Provider name'; if (this.mode === 'custom-url' || this.mode === 'preset-url') return 'Base URL'; if (this.mode === 'custom-model') return 'Model ID'; + if (this.mode === 'api-key') return 'API Key (optional)'; return 'API Key'; } @@ -724,7 +765,10 @@ export class TuiProviderOnboarding implements Component, Focusable { return 'enter test, save, and use · tab API Key · esc close details'; return '↑↓ select · type to search · tab API Key · enter details · esc back'; } - if (this.mode === 'api-key') return 'enter test, save, and use · esc back'; + if (this.mode === 'api-key') + return this.localEndpoint + ? 'enter connect without a key · type a key for a guarded server · esc back' + : 'enter test, save, and use · leave empty for no authentication · esc back'; if ( this.mode === 'preset-url' || this.mode === 'custom-name' || diff --git a/packages/tui/test/unit/tui-provider-editor.test.ts b/packages/tui/test/unit/tui-provider-editor.test.ts index 33e90aa2..73b3b5cc 100644 --- a/packages/tui/test/unit/tui-provider-editor.test.ts +++ b/packages/tui/test/unit/tui-provider-editor.test.ts @@ -16,11 +16,14 @@ const provider: KcodeProviderView = { configRevision: "rev-1", models: [{ modelId: "chat", selected: true }, { modelId: "reasoner" }], }; -function setup(onSave = vi.fn(async () => ({ success: true }))) { +function setup( + onSave = vi.fn(async () => ({ success: true })), + overrides: Partial = {}, +) { const onSaved = vi.fn(); const onCancel = vi.fn(); const editor = new TuiProviderEditor({ - provider, + provider: { ...provider, ...overrides }, onSave, onSaved, onCancel, @@ -119,6 +122,38 @@ describe("TuiProviderEditor", () => { ); }); + it("tests and saves a connection that carries no key", async () => { + const h = setup(undefined, { hasApiKey: false }); + + expect(stripAnsi(h.editor.render(200).join("\n"))).toContain( + "Not set · a request carries no credential", + ); + + h.save(); + + await vi.waitFor(() => expect(h.onSave).toHaveBeenCalledOnce()); + const calls = h.onSave.mock.calls as unknown as Array<[Record]>; + expect(calls[0]?.[0]).not.toHaveProperty("apiKey"); + expect(h.onSaved).toHaveBeenCalledWith(false); + }); + + it("clears a saved key when the API Key field is submitted empty", async () => { + const h = setup(); + + h.editor.handleInput("\r"); + h.editor.handleInput("\r"); + expect(stripAnsi(h.editor.render(200).join("\n"))).toContain( + "Cleared · no credential will be sent", + ); + + h.save(); + + await vi.waitFor(() => expect(h.onSave).toHaveBeenCalledOnce()); + const calls = h.onSave.mock.calls as unknown as Array<[Record]>; + expect(calls[0]?.[0]).toMatchObject({ apiKey: "" }); + expect(h.onSaved).toHaveBeenCalledWith(true); + }); + it("does not reopen a disposed editor after a pending save", async () => { let resolve!: (result: { success: boolean }) => void; const h = setup( diff --git a/packages/tui/test/unit/tui-provider-onboarding.test.ts b/packages/tui/test/unit/tui-provider-onboarding.test.ts index 5af7067a..d2ca6569 100644 --- a/packages/tui/test/unit/tui-provider-onboarding.test.ts +++ b/packages/tui/test/unit/tui-provider-onboarding.test.ts @@ -335,6 +335,60 @@ describe("TuiProviderOnboarding", () => { }); }); + it("connects a local model from the catalogue without a key", async () => { + const onSave = vi.fn(async () => ({ + success: true, + provider: { providerId: "custom_provider:local-model" }, + })); + const onComplete = vi.fn(async () => undefined); + const onboarding = new TuiProviderOnboarding({ + templates: [], + onSave, + onComplete, + onCancel: vi.fn(), + requestRender: vi.fn(), + }); + + const catalogue = stripAnsi(onboarding.render(90).join("\n")); + expect(catalogue).toContain("Local model"); + expect(catalogue).toContain("OpenAI-compatible server · API key optional"); + + // Down to the local entry, which pre-fills the endpoint it starts from. + onboarding.handleInput("\u001b[B"); + onboarding.handleInput("\r"); + onboarding.handleInput("\r"); + const urlStep = stripAnsi(onboarding.render(90).join("\n")); + expect(urlStep).toContain("Base URL"); + expect(urlStep).toContain("http://localhost:11434/v1"); + + onboarding.handleInput("\r"); + onboarding.handleInput("qwen3-local"); + onboarding.handleInput("\r"); + const keyStep = stripAnsi(onboarding.render(90).join("\n")); + expect(keyStep).toContain("API Key (optional)"); + expect(keyStep).toContain("left empty"); + + // Enter on the empty field connects the endpoint with no credential. + onboarding.handleInput("\r"); + + await vi.waitFor(() => expect(onComplete).toHaveBeenCalledOnce()); + expect(onSave).toHaveBeenCalledWith({ + name: "Local model", + baseUrl: "http://localhost:11434/v1", + apiFormat: "openai-completions", + models: [ + { + modelId: "qwen3-local", + displayName: "qwen3-local", + configurationSource: "manual", + toolCall: true, + }, + ], + modelId: "qwen3-local", + saveAndUse: true, + }); + }); + it("keeps the form open when Runtime rejects the connection test", async () => { const onComplete = vi.fn(); const onboarding = new TuiProviderOnboarding({ diff --git a/release/public-source.json b/release/public-source.json index 86a4b277..6419f307 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -114,6 +114,7 @@ "packages/agent-core/test/unit/image-dimensions.test.ts", "packages/agent-core/test/unit/image-fixtures.ts", "packages/agent-core/test/unit/pi-turn-runner/llm-retry.test.ts", + "packages/agent-core/test/unit/pi-turn-runner/unauthenticated-endpoint.test.ts", "packages/agent-extension/package.json", "packages/agent-extension/src/context-manager.ts", "packages/agent-extension/src/index.ts", @@ -2724,6 +2725,7 @@ "packages/shared/src/channel-format-hints.ts", "packages/shared/src/channel-plugin.ts", "packages/shared/src/channel-route.ts", + "packages/shared/src/credential-headers.ts", "packages/shared/src/cron-model.ts", "packages/shared/src/cron-purpose.ts", "packages/shared/src/daily-signin.ts", diff --git a/test/byok.test.mjs b/test/byok.test.mjs index 2d000177..25506650 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -826,3 +826,158 @@ function cancellationTest(cancellation) { ); }; } + +test( + "connects and runs a local endpoint that needs no authentication", + { timeout: 90000 }, + async (t) => { + const fixtureDir = mkdtempSync(path.join(tmpdir(), "kinetick-code-no-auth-")); + const dataDir = path.join(fixtureDir, "data"); + const workspaceDir = path.join(fixtureDir, "workspace"); + mkdirSync(workspaceDir); + const networkAudit = path.join(dataDir, "network-audit.log"); + // Every request the stand-in endpoint sees, with the credential headers the + // runtime must not send. + const requests = []; + const server = createServer(async (req, res) => { + let raw = ""; + for await (const chunk of req) raw += chunk; + requests.push({ + url: req.url, + authorization: req.headers.authorization, + apiKey: req.headers["x-api-key"], + body: raw ? JSON.parse(raw) : undefined, + }); + if (!req.url?.endsWith("/chat/completions")) { + res.writeHead(404, { "content-type": "application/json" }).end( + JSON.stringify({ error: { message: "Only Chat is served" } }), + ); + return; + } + if (!raw || !JSON.parse(raw).stream) { + // The connection test probes without streaming first. + res.writeHead(200, { "content-type": "application/json" }).end( + JSON.stringify({ + id: "fixture", + object: "chat.completion", + model: "local-model", + choices: [ + { + index: 0, + message: { role: "assistant", content: "LOCAL_NO_AUTH_OK" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ); + return; + } + res.writeHead(200, { "content-type": "text/event-stream" }); + for (const chunk of [ + { + choices: [ + { + index: 0, + delta: { role: "assistant", content: "LOCAL_NO_AUTH_OK" }, + finish_reason: null, + }, + ], + }, + { + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]) { + res.write( + `data: ${JSON.stringify({ id: "fixture", object: "chat.completion.chunk", created: 1, model: "local-model", ...chunk })}\n\n`, + ); + } + res.end("data: [DONE]\n\n"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + rmSync(fixtureDir, { recursive: true, force: true }); + }); + const baseUrl = `http://127.0.0.1:${server.address().port}/v1`; + // No provider key in the environment: this fixture is the local server case, + // and the endpoint must be usable without one. + const env = { + MINIMAX_DATA_DIR: dataDir, + MAVIS_DATA_DIR: dataDir, + MCODE_TEST_ALLOWED_ORIGIN: new URL(baseUrl).origin, + MCODE_TEST_NETWORK_AUDIT: networkAudit, + MCODE_TEST_MANAGED_OFFLINE: "1", + NODE_OPTIONS: `--import=${new URL("./network-deny.mjs", import.meta.url).href}`, + }; + const run = (args) => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cli, ...args], { + cwd: workspaceDir, + env: { ...withoutProxyEnvironment(process.env), ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + child.stdout.destroy(); + child.stderr.destroy(); + }, 35000); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(timer); + code === 0 + ? resolve(stdout) + : reject(new Error(`CLI exited ${code}: ${stderr}\n${stdout}`)); + }); + }); + + // Adding, testing and using the connection all happen without a credential. + assert.match( + await run([ + "provider", "add", "--name", "Local model", "--base-url", baseUrl, + "--api-format", "openai-completions", "--model", "local-model", "--use", + ]), + /Provider added and selected/, + ); + const config = parseYaml(readFileSync(path.join(dataDir, "config.yaml"), "utf8")); + assert.equal(config.defaultModel, "custom_provider:local-model/local-model"); + assert.deepEqual(config.custom_provider["local-model"].options, { baseURL: baseUrl }); + assert.equal(config.custom_provider["local-model"].options.apiKey, undefined); + assert.equal(config.custom_provider["local-model"].options.authMode, undefined); + + const listed = JSON.parse(await run(["provider", "list", "--json"])).providers.find( + (provider) => provider.name === "Local model", + ); + assert.equal(listed.active, true); + assert.equal(listed.hasApiKey, false); + + assert.match(await run([ + "exec", "LOCAL_ENDPOINT_OK", "--timeout", "20s", "--max-steps", "1", + ]), /LOCAL_NO_AUTH_OK/); + + const chatRequests = requests.filter(({ body }) => Array.isArray(body?.messages)); + assert.ok(chatRequests.length >= 2, "The connection test and the turn must both reach Chat"); + for (const request of requests) { + assert.equal( + request.authorization, + undefined, + `No Authorization header may be sent: ${request.url}`, + ); + assert.equal(request.apiKey, undefined, `No x-api-key header may be sent: ${request.url}`); + } + assert.equal(existsSync(networkAudit), false, "No outbound network attempt is allowed"); + }, +); diff --git a/test/vitest-suites.json b/test/vitest-suites.json index e226eb17..a3640d12 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -98,6 +98,7 @@ "packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts", "packages/tui/test/unit/tui/widgets/editor/editor-behavior.test.ts", "packages/agent-core/test/unit/pi-turn-runner/llm-retry.test.ts", + "packages/agent-core/test/unit/pi-turn-runner/unauthenticated-endpoint.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.test.ts", "packages/local-runtime-v2/src/services.test.ts", "packages/local-runtime-v2/src/application/session/pin-application.test.ts", From b7a81cdac4bfeaebc88cefa9d662935ae9abe9ce Mon Sep 17 00:00:00 2001 From: hermes-agent Date: Wed, 23 Sep 2026 23:44:40 +0200 Subject: [PATCH 2/2] docs: record the keyless local endpoint The verification record carries the code revision, the full gate list with counts, the 16 tests the change adds, the end-to-end BYOK run and the PTY session, and its NOT RUN list. The provider documentation now describes the keyless case where it already described a local server: README and docs/examples.md state the lookup rule for the key variable instead of promising that no credential is ever sent when the flag is omitted. --- README.md | 2 +- docs/examples.md | 2 +- docs/verification.md | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a32a544e..c9b2a710 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ kcode `--use` tests the first listed model before saving and selecting it. A failed connection test saves nothing. Omit `--use` to save without testing or changing the default model. For custom/local models, add `--context-limit 32768 --output-limit 4096` (use your server's actual limits). Each value must be a positive safe integer and applies to every repeated `--model`. Inspect configured limits with `kcode provider list --json`. Omitting these flags preserves the existing model-limit defaults. -Supported API formats: `openai-completions`, `openai-responses`, and `anthropic-messages`. An endpoint that needs no authentication — a server you run locally, typically — is a provider without a key: omit `--api-key-env`, and no credential header is sent. See the [model examples](docs/examples.md#2-choose-your-own-model) for environment variable setup, connection checks, and model overrides for a single run. +Supported API formats: `openai-completions`, `openai-responses`, and `anthropic-messages`. An endpoint that needs no authentication — a server you run locally, typically — is a provider without a key: omit `--api-key-env`, leave `MCODE_PROVIDER_API_KEY` unset, and the connection is stored with no credential, so no request to it carries one. See the [model examples](docs/examples.md#2-choose-your-own-model) for environment variable setup, connection checks, and model overrides for a single run. diff --git a/docs/examples.md b/docs/examples.md index 6fe7a16e..296221df 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -56,7 +56,7 @@ pnpm kcode exec "Explain this project's test entry points" --model Replace the example URL, model name, and IDs with your configuration and the IDs returned by the list command. `--use` tests the first listed model, then saves the provider and selects that model as the default. A failed connection test exits nonzero without saving or changing the default; correct the URL, key, or first model ID and retry. Omit `--use` to save without a connection test or default-model change. `exec --model` overrides only the current run. Backslash line continuations are for POSIX shells; use a single line in PowerShell. -A local server that checks no credential needs no key: omit `--api-key-env`, and configure its actual token limits explicitly: +A local server that checks no credential needs no key: omit `--api-key-env` and leave `MCODE_PROVIDER_API_KEY` unset, since that variable is the default when the flag is absent — then configure the server's actual token limits explicitly: ```bash pnpm kcode provider add --name local-models --base-url http://localhost:8080/v1 \ diff --git a/docs/verification.md b/docs/verification.md index 5a8f46be..bbe77b51 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -156,6 +156,22 @@ A real PTY session of the built CLI with the same isolated config showed the pan NOT RUN: a real OpenRouter call (the endpoint above is a local stand-in, so the resolution path is proven, not the service), and Windows or macOS execution. +### Local endpoint without authentication, 2026-09-23 + +Verification results for the keyless local-endpoint support at code revision `e2a4dbf` (the documentation commit that follows changes no code). + +A model served on an OpenAI-compatible endpoint that checks no credential — Ollama, llama.cpp, vLLM, LM Studio — is now a provider without a key. Nothing infers "no authentication" from a URL and no new setting was introduced: an entry that carries no key declares no credential scheme, and no request to it is sent with one. `packages/shared/src/credential-headers.ts` names the credential headers the three protocols use, clears them on the request's own options (the last header source `pi-ai` merges, and the one its SDKs read a `null` from as "remove this default header"), and owns the placeholder key the vendored transport requires — `openai-completions` throws on a falsy key, so the resolver hands such a model one that never reaches the wire. + +`pnpm verify` passed all 14 gates on Linux arm64 with Node.js 26.5.1 and pnpm 9.12.0: source inventory (4,239 files), generated paths (127 package exports), source export, release tooling (44 tests), typecheck, build, standalone boundary, egress boundary, built artifacts (4 tests), capabilities (4,649 tests in 175 files), status contract (9 tests), CLI/ACP smoke (21 tests), offline BYOK (4 tests), and permission policy (142 tests). `test:windows`, `test:sandbox` and `test:release-package` are skipped on this platform. + +The change adds 16 tests — 15 to the capability suite (one of them in a new file, which moves its file count from 174 to 175) and 1 to the offline BYOK suite, whose test count moves from 3 to 4. In `packages/agent-core/test/unit/pi-turn-runner/unauthenticated-endpoint.test.ts`, 7 cases pin the header clearing: both credential headers are cleared while the transport still receives the key it requires, other headers survive, a header the connection declares itself is preserved, and a model without the flag is left untouched. Two cases in the model-system service suite pin the saved shape (a candidate without a key stores no `apiKey` and no `authMode`, and its gated connection test sends no credential) and the empty-key convention (an empty key is stored as no credential, not rejected as invalid). Two resolver cases pin the resolved config on the custom-provider route and on the builtin route: the placeholder key plus `unauthenticatedEndpoint`, and no credential header. The reworked plan case states the new rule (a keyless plan is returned instead of a refusal) and keeps the incomplete-endpoint refusal covered. One case in the shared token-counter suite pins that a keyless endpoint is counted locally rather than probed. One onboarding case pins the catalogue's **Local model** entry and its keyless save; two editor cases pin a connection that carries no key being tested and saved, and an empty API Key field clearing a saved key. + +The end-to-end proof is the offline BYOK suite's new case, which runs the built CLI against a loopback stand-in and an isolated data directory with no provider key in the environment: `provider add` saves the connection with `options` exactly `{ baseURL }`, `provider list` reports `hasApiKey: false`, the connection test answers, and `exec` completes a real turn against the stand-in. Every request the stand-in saw is asserted to carry neither `Authorization` nor `x-api-key` — including the auxiliary `POST /v1/responses/input_tokens` counter probe that the first run of this case caught, which is why the remote token counter now stays local for such an endpoint. + +A real PTY session of the built CLI, driven against the same kind of stand-in on `127.0.0.1:11434`, showed the new surface: the catalogue renders `› Local model OpenAI-compatible server · API key optional`, the flow reaches the step labelled `API Key (optional)` under `Optional: a server that needs no authentication is connected with the field left empty`, submitting it empty connects (`Provider added: Local model`), and the panel then lists that connection as `Enabled · 1 model` beside an active `✦ local-model` status line. The stand-in logged one request from that session — `POST /v1/chat/completions`, `stream: false` — with `authorization: null` and `x-api-key: null`. + +NOT RUN: any live provider or service call (every endpoint above is a local stand-in, so the resolution and request paths are proven, not a third-party service); the legacy v1 host's own model resolver, which still requires a key and is not the runtime the product drives; and Windows or macOS execution. + ### Release-preparation verification, 2026-09-12 `pnpm verify` was run on `3de31f0e365e635c52d661c0a14c9ee69e65f099` in an isolated worktree after a frozen-lockfile install, on macOS arm64 with Node.js 26.4.0 and pnpm 9.12.0.