Compare commits
10
Commits
240adb52b3
...
8007a6480c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8007a6480c | ||
|
|
adea6585c0 | ||
|
|
8d26dbd374 | ||
|
|
f9879a20ee | ||
|
|
3049ce1544 | ||
|
|
9296d3dc31 | ||
|
|
8416e76d9e | ||
|
|
245c352996 | ||
|
|
c60d39398a | ||
|
|
e1c0a024ce |
@@ -17,11 +17,21 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
# `npm test` includes tests/test-pi-local.mjs, which drives a real pi
|
||||
# binary against the mock API and otherwise skips silently.
|
||||
- name: Install pi
|
||||
run: |
|
||||
npm install -g @earendil-works/pi-coding-agent@latest
|
||||
echo "PI_BIN=$(npm prefix -g)/bin/pi" >> "$GITHUB_ENV"
|
||||
- name: Verify pi starts
|
||||
run: '"$PI_BIN" --version'
|
||||
- run: npm test
|
||||
env:
|
||||
PI_LOCAL_REQUIRED: "1"
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -46,9 +56,9 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: 1.4.0
|
||||
- run: npm ci
|
||||
- name: Install Oh My Pi
|
||||
run: |
|
||||
|
||||
@@ -17,9 +17,9 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
PI_VERSION: 0.82.1
|
||||
BUN_VERSION: 1.3.11
|
||||
NODE_VERSION: 22.19.0
|
||||
PI_VERSION: 0.84.4
|
||||
BUN_VERSION: 1.4.0
|
||||
NODE_VERSION: 22.23.2
|
||||
MEMORY_BENCHMARK_RUNS: 6
|
||||
MEMORY_BENCHMARK_WARMUP_MS: 2500
|
||||
MEMORY_BENCHMARK_SAMPLES: 12
|
||||
|
||||
@@ -2,8 +2,36 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Refresh the generated Command Code capability catalog from `command-code@1.44.0` to `command-code@1.53.1`. `deepseek/deepseek-v4.1-flash` now advertises image input and its `low`, `high`, and `max` reasoning efforts, so pi forwards attached images and exposes the full thinking-level selector instead of the text-only, level-less defaults. New `gpt-6-astra`, `xai/grok-4.6`, and `inclusionai/ling-3.0-flash-sante:free` metadata comes along.
|
||||
- Drop the now-obsolete manual Meta Muse Spark effort overrides: upstream ships efforts for `meta/muse-spark-1.1` through `1.3-contributor` and `MiniMaxAI/MiniMax-M3`, so `src/commandcode-catalog-overrides.ts` is empty.
|
||||
|
||||
## 0.6.4 - 2026-09-03
|
||||
|
||||
- Refresh the generated Command Code capability catalog from `command-code@1.40.1` to `command-code@1.44.0`, adding current image-input, reasoning, effort, and output-limit metadata for newly published models.
|
||||
- Expose selectable thinking levels (`minimal`, `low`, `medium`, `high`, `xhigh`) for `meta/muse-spark-1.3` and `meta/muse-spark-1.3-contributor`, so Pi and Oh My Pi forward the selected `reasoning_effort` instead of keeping thinking disabled.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @heie54 — added and validated Muse Spark 1.3 reasoning support (#80).
|
||||
|
||||
## 0.6.3 - 2026-09-02
|
||||
|
||||
- Fix Oh My Pi chat returning `401 Invalid 'Authorization' header` after `/login`: OMP kept the unresolved `$COMMAND_CODE_API_KEY` placeholder as a literal config API key that shadowed its stored credentials and was sent as the Bearer token. The placeholder is now registered only on pi, where it keeps the API-key login method and `--api-key` working next to OAuth; on OMP the provider omits `apiKey` unless a real key is configured. Placeholders passed by the host are also resolved or stripped on the Provider API and compat stream paths, and the legacy generate transport resolves its key through the same rule.
|
||||
- Cover stored `/login` OAuth and API-key credentials, `--api-key`, and env keys end to end on both pi and Oh My Pi, asserting the exact Bearer token the mock API receives. CI now runs the pi end-to-end suite against a real `pi` binary instead of skipping it.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ebreen — reported and diagnosed the Oh My Pi `/login` 401, and opened the fix that this release builds on (#78).
|
||||
|
||||
## 0.6.2 - 2026-09-02
|
||||
|
||||
- Fix `omp plugin install` on Oh My Pi 18.x, which rejected 0.6.1 because its pi-ai lacks the `registerApiProvider` export; the compat registration now resolves at runtime and is skipped on hosts that register custom APIs themselves.
|
||||
- Run the Oh My Pi compatibility suite against a real `omp` binary in CI as a required check, and assert there that the extension loads against OMP's bundled pi packages.
|
||||
- Pin the CI memory benchmark and Oh My Pi jobs to Bun 1.4.0, Node 22.23.2, and pi 0.84.4.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @AmeMizuki — reported the failing `omp plugin install` on Oh My Pi 18.1.2.
|
||||
|
||||
## 0.6.1 - 2026-09-01
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@ COMMANDCODE_E2E_PROVIDER_API_KEY_FILE=/path/to/provider-key npm run test:e2e:liv
|
||||
|
||||
Use `npm run test:e2e:live:all` with the Go and GOAT file variables to run both subscription transports sequentially. Store keys in a secret manager and export each one to a new mode-`0600` temporary file for the test; never add key files to the repository. Direct `*_API_KEY` variables are intended primarily for protected CI secrets.
|
||||
|
||||
### pi end-to-end
|
||||
|
||||
`tests/test-pi-local.mjs` runs the extension inside a real `pi` binary against a mock Command Code API, including every credential source (`/login` OAuth and API-key credentials, `--api-key`, env keys). It skips locally when `pi` is not on `PATH`; CI installs pi and runs it as part of `npm test` with `PI_LOCAL_REQUIRED=1`. Point `PI_BIN` at another pi executable to test against a specific version.
|
||||
|
||||
### Oh My Pi compatibility
|
||||
|
||||
`tests/test-omp-compat.mjs` runs the extension inside a real `omp` binary against a mock Command Code API. It skips locally when `omp` is not on `PATH`; CI installs Oh My Pi and runs it as a required check with `OMP_COMPAT_REQUIRED=1`, so a change that only loads on pi fails CI instead of the next `omp plugin install`.
|
||||
|
||||
@@ -41,6 +41,8 @@ Run `/login` in pi or OMP. Select **Use a subscription**, then **Command Code**.
|
||||
|
||||
If automatic transfer from the browser fails, copy the API key shown by Command Code and paste it into the terminal prompt.
|
||||
|
||||
On Oh My Pi, `/login` stores those credentials in OMP's credential store and chat uses them directly. If chat still returns `401 Invalid 'Authorization' header`, restart OMP after `/login` and confirm `/commandcode-quota` shows your account.
|
||||
|
||||
### Environment variable
|
||||
|
||||
```sh
|
||||
@@ -86,7 +88,7 @@ Other extensions that stream with the active Command Code model, such as backgro
|
||||
|
||||
### Reasoning support
|
||||
|
||||
Reasoning capability and selectable effort levels follow the official CLI catalog independently. Models can therefore be marked as reasoning-capable even when Command Code chooses their depth automatically. Models with explicit effort support register a model-specific `thinkingLevelMap`, so pi and OMP expose only valid levels. For a few reasoning models the CLI catalog ships no effort levels although the endpoint accepts `reasoning_effort`; `src/commandcode-catalog-overrides.ts` adds a manual level set for those (currently `meta/muse-spark-1.1`, `meta/muse-spark-1.2`, and `meta/muse-spark-1.2-contributor`) on top of the generated catalog, and the tests fail once upstream publishes its own levels so the override gets removed. Pi's native OpenAI- and Anthropic-compatible providers translate the selected level for Provider API accounts; the existing Command Code generate transport sends the matching `reasoning_effort` for Go accounts.
|
||||
Reasoning capability and selectable effort levels follow the official CLI catalog independently. Models can therefore be marked as reasoning-capable even when Command Code chooses their depth automatically. Models with explicit effort support register a model-specific `thinkingLevelMap`, so pi and OMP expose only valid levels, including the opt-in `xhigh` and `max` levels. `src/commandcode-catalog-overrides.ts` can add a manual level set for reasoning models that the CLI catalog ships without efforts; it is currently empty because upstream publishes efforts for every selectable model, and the tests fail once upstream publishes levels for a model that still has a manual override. Pi's native OpenAI- and Anthropic-compatible providers translate the selected level for Provider API accounts; the existing Command Code generate transport sends the matching `reasoning_effort` for Go accounts.
|
||||
|
||||
List Command Code models from the terminal:
|
||||
|
||||
@@ -137,7 +139,7 @@ The following environment variables are intended for tests, local mocks, and com
|
||||
|
||||
## Image input
|
||||
|
||||
The provider advertises image input only for models marked with the `image` input modality in the official Command Code CLI model catalog. The capability snapshot currently follows `command-code@1.40.1`; unknown models default to text-only until their upstream metadata is reviewed. A daily GitHub Actions job synchronizes the CLI version, image capabilities, reasoning flags, reasoning efforts, and model-specific output limits with the latest published CLI package and opens or updates a reviewable pull request when they change. Pricing remains manually reviewed because temporary promotions and long-context tiers require explicit review.
|
||||
The provider advertises image input only for models marked with the `image` input modality in the official Command Code CLI model catalog. The capability snapshot currently follows `command-code@1.53.1`; unknown models default to text-only until their upstream metadata is reviewed. A daily GitHub Actions job synchronizes the CLI version, image capabilities, reasoning flags, reasoning efforts, and model-specific output limits with the latest published CLI package and opens or updates a reviewable pull request when they change. Pricing remains manually reviewed because temporary promotions and long-context tiers require explicit review.
|
||||
|
||||
For vision-capable models, Pi's native provider adapters forward image blocks from user messages and tool results using the documented OpenAI or Anthropic message schema. Unknown and text-only models remain marked text-only in Pi.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { join } from "node:path"
|
||||
|
||||
import { getConfiguredApiKey } from "./src/api-key.ts"
|
||||
import { pickCommandCodeApiKey, withResolvedCommandCodeApiKey } from "./src/converters.ts"
|
||||
import { createStreamCommandCode } from "./src/core.ts"
|
||||
import { calculateCommandCodeCost } from "./src/cost.ts"
|
||||
import {
|
||||
@@ -54,10 +55,40 @@ type CompatStreamFunction = (
|
||||
* and registers custom APIs itself inside `registerProvider`. Resolve the
|
||||
* function at runtime so the extension loads on both hosts.
|
||||
*/
|
||||
function registerCompatApiProvider(stream: CompatStreamFunction): void {
|
||||
function compatApiProviderRegistrar(): ((...args: unknown[]) => unknown) | undefined {
|
||||
const register = (piAiCompat as { registerApiProvider?: unknown }).registerApiProvider
|
||||
if (typeof register !== "function") return
|
||||
register({ api: COMMAND_CODE_API, stream, streamSimple: stream }, COMPAT_SOURCE_ID)
|
||||
return typeof register === "function" ? (register as (...args: unknown[]) => unknown) : undefined
|
||||
}
|
||||
|
||||
function registerCompatApiProvider(stream: CompatStreamFunction): void {
|
||||
compatApiProviderRegistrar()?.(
|
||||
{ api: COMMAND_CODE_API, stream, streamSimple: stream },
|
||||
COMPAT_SOURCE_ID,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `apiKey` handed to `registerProvider` means different things per host.
|
||||
*
|
||||
* pi parses `$COMMAND_CODE_API_KEY` as an env template: unresolved means
|
||||
* "not configured", so `/login` credentials and `--api-key` take over, and
|
||||
* the entry keeps the API-key auth method registered next to OAuth. Without
|
||||
* it pi composes an OAuth-only provider and drops stored `api_key`
|
||||
* credentials and `--api-key`.
|
||||
*
|
||||
* Oh My Pi has no template notion: an unresolved value stays a literal config
|
||||
* override that shadows its `/login` credential store and is sent verbatim as
|
||||
* `Authorization: Bearer $COMMAND_CODE_API_KEY`. There, omit `apiKey` unless
|
||||
* a real key is configured; OMP then reads env keys and stored credentials
|
||||
* itself.
|
||||
*
|
||||
* Hosts are told apart by the same `registerApiProvider` probe used for the
|
||||
* compat registry: pi exports it, OMP does not.
|
||||
*/
|
||||
function providerApiKey(): string | undefined {
|
||||
const configured = pickCommandCodeApiKey(getConfiguredApiKey(), undefined)
|
||||
if (configured) return configured
|
||||
return compatApiProviderRegistrar() ? "$COMMAND_CODE_API_KEY" : undefined
|
||||
}
|
||||
|
||||
function commandCodeHeaders(): Record<string, string> | undefined {
|
||||
@@ -76,7 +107,7 @@ function createProviderConfig(
|
||||
return {
|
||||
name: "Command Code",
|
||||
baseUrl: apiBase,
|
||||
apiKey: getConfiguredApiKey() ?? "$COMMAND_CODE_API_KEY",
|
||||
apiKey: providerApiKey(),
|
||||
api: COMMAND_CODE_API,
|
||||
streamSimple: streamCommandCode,
|
||||
headers,
|
||||
@@ -132,15 +163,18 @@ export default async function (pi: ExtensionAPI) {
|
||||
calculateCost: calculateCommandCodeCost,
|
||||
apiBase: legacyApiBase(apiBase),
|
||||
})
|
||||
const resolveStreamOptions = (options?: Parameters<typeof streamNativeProvider>[2]) =>
|
||||
withResolvedCommandCodeApiKey(options, getConfiguredApiKey())
|
||||
const transport = createCommandCodeTransportRouter({
|
||||
createStream: () => new AssistantMessageEventStream(),
|
||||
streamProvider: (model, context, options) =>
|
||||
streamNativeProvider(
|
||||
{ ...model, api: apiForModelId(model.id), compat: model.compatConfig ?? model.compat },
|
||||
context,
|
||||
options,
|
||||
resolveStreamOptions(options),
|
||||
),
|
||||
streamGenerate,
|
||||
streamGenerate: (model, context, options) =>
|
||||
streamGenerate(model, context, resolveStreamOptions(options)),
|
||||
})
|
||||
|
||||
// pi dispatches the main chat through the registered provider, but sibling
|
||||
@@ -149,13 +183,9 @@ export default async function (pi: ExtensionAPI) {
|
||||
// api-registry, which knows nothing about extension providers. Register the
|
||||
// custom api there so those calls reach the same transport. The registry
|
||||
// resolves no credentials for extension providers, so fall back to the
|
||||
// configured key when the caller passes none.
|
||||
// configured key when the caller passes none or a placeholder.
|
||||
const compatStream: CompatStreamFunction = (model, context, options) =>
|
||||
transport.stream(
|
||||
model,
|
||||
context,
|
||||
options?.apiKey ? options : { ...options, apiKey: getConfiguredApiKey() },
|
||||
) as AssistantMessageEventStream
|
||||
transport.stream(model, context, resolveStreamOptions(options)) as AssistantMessageEventStream
|
||||
registerCompatApiProvider(compatStream)
|
||||
|
||||
pi.on("message_end", async (event, ctx) => {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-commandcode-provider",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-commandcode-provider",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.4",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "25.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pi-commandcode-provider",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.4",
|
||||
"description": "pi custom provider for Command Code API (commandcode.ai)",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
|
||||
@@ -11,13 +11,9 @@ import type { CommandCodeReasoningEffort } from "./commandcode-catalog.ts"
|
||||
*
|
||||
* Add a model only when the effort parameter is known to be accepted by the
|
||||
* Command Code endpoint; remove it once the CLI catalog ships its own efforts.
|
||||
* The map is currently empty: upstream published efforts for Meta Muse Spark
|
||||
* 1.1-1.3 and MiniMax M3, so no manual policy is needed.
|
||||
*/
|
||||
export const MODEL_EFFORT_OVERRIDES: Readonly<
|
||||
Record<string, readonly CommandCodeReasoningEffort[]>
|
||||
> = {
|
||||
// Meta Muse Spark: the CLI ships no effort levels, but the endpoint accepts
|
||||
// `reasoning_effort` for these models and other hosts expose the same set.
|
||||
"meta/muse-spark-1.1": ["minimal", "low", "medium", "high", "xhigh"],
|
||||
"meta/muse-spark-1.2": ["minimal", "low", "medium", "high", "xhigh"],
|
||||
"meta/muse-spark-1.2-contributor": ["minimal", "low", "medium", "high", "xhigh"],
|
||||
}
|
||||
> = {}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export const COMMAND_CODE_CLI_VERSION = "1.40.1"
|
||||
export const COMMAND_CODE_CLI_VERSION = "1.53.1"
|
||||
|
||||
export type CommandCodeInputType = "text" | "image"
|
||||
export type CommandCodeReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
|
||||
|
||||
/**
|
||||
* Generated from command-code@1.40.1 by `npm run sync:commandcode-catalog`.
|
||||
* Generated from command-code@1.53.1 by `npm run sync:commandcode-catalog`.
|
||||
* Do not edit manually.
|
||||
*/
|
||||
export const MODEL_INPUT_MODALITIES: Readonly<Record<string, readonly CommandCodeInputType[]>> = {
|
||||
@@ -17,11 +17,13 @@ export const MODEL_INPUT_MODALITIES: Readonly<Record<string, readonly CommandCod
|
||||
"claude-sonnet-4-6": ["text", "image"],
|
||||
"claude-sonnet-5": ["text", "image"],
|
||||
"deepseek/deepseek-v4-flash-vision-exp": ["text", "image"],
|
||||
"deepseek/deepseek-v4.1-flash": ["text", "image"],
|
||||
"google/gemini-3.1-flash-lite": ["text", "image"],
|
||||
"google/gemini-3.5-flash": ["text", "image"],
|
||||
"google/gemini-3.5-flash-lite": ["text", "image"],
|
||||
"google/gemini-3.6-flash": ["text", "image"],
|
||||
"google/gemini-3.7-flash": ["text", "image"],
|
||||
"google/gemini-3.8-flash": ["text", "image"],
|
||||
"gpt-5.3-codex": ["text", "image"],
|
||||
"gpt-5.4": ["text", "image"],
|
||||
"gpt-5.4-mini": ["text", "image"],
|
||||
@@ -29,9 +31,12 @@ export const MODEL_INPUT_MODALITIES: Readonly<Record<string, readonly CommandCod
|
||||
"gpt-5.6-luna": ["text", "image"],
|
||||
"gpt-5.6-sol": ["text", "image"],
|
||||
"gpt-5.6-terra": ["text", "image"],
|
||||
"gpt-6-astra": ["text", "image"],
|
||||
"meta/muse-spark-1.1": ["text", "image"],
|
||||
"meta/muse-spark-1.2": ["text", "image"],
|
||||
"meta/muse-spark-1.2-contributor": ["text", "image"],
|
||||
"meta/muse-spark-1.3": ["text", "image"],
|
||||
"meta/muse-spark-1.3-contributor": ["text", "image"],
|
||||
"MiniMaxAI/MiniMax-M3": ["text", "image"],
|
||||
"moonshotai/Kimi-K2.5": ["text", "image"],
|
||||
"moonshotai/Kimi-K2.6": ["text", "image"],
|
||||
@@ -44,11 +49,13 @@ export const MODEL_INPUT_MODALITIES: Readonly<Record<string, readonly CommandCod
|
||||
"Qwen/Qwen3.8-27B": ["text", "image"],
|
||||
"Qwen/Qwen3.8-Flash": ["text", "image"],
|
||||
"Qwen/Qwen3.8-Max": ["text", "image"],
|
||||
"Qwen/Qwen3.8-Max-0902": ["text", "image"],
|
||||
"sakana/fugu-ultra": ["text", "image"],
|
||||
"stepfun/Step-3.7-Flash": ["text", "image"],
|
||||
"thinkingmachines/inkling": ["text", "image"],
|
||||
"thinkingmachines/inkling-small": ["text", "image"],
|
||||
"xai/grok-4.5": ["text", "image"],
|
||||
"xai/grok-4.6": ["text", "image"],
|
||||
"xiaomi/mimo-v2.5": ["text", "image"],
|
||||
"z-ai/glm-5.3-flash": ["text", "image"],
|
||||
}
|
||||
@@ -65,11 +72,13 @@ export const MODEL_REASONING: Readonly<Record<string, true>> = {
|
||||
"deepseek/deepseek-v4-flash-fast": true,
|
||||
"deepseek/deepseek-v4-flash-vision-exp": true,
|
||||
"deepseek/deepseek-v4-pro": true,
|
||||
"deepseek/deepseek-v4.1-flash": true,
|
||||
"google/gemini-3.1-flash-lite": true,
|
||||
"google/gemini-3.5-flash": true,
|
||||
"google/gemini-3.5-flash-lite": true,
|
||||
"google/gemini-3.6-flash": true,
|
||||
"google/gemini-3.7-flash": true,
|
||||
"google/gemini-3.8-flash": true,
|
||||
"gpt-5.3-codex": true,
|
||||
"gpt-5.4": true,
|
||||
"gpt-5.4-mini": true,
|
||||
@@ -77,9 +86,14 @@ export const MODEL_REASONING: Readonly<Record<string, true>> = {
|
||||
"gpt-5.6-luna": true,
|
||||
"gpt-5.6-sol": true,
|
||||
"gpt-5.6-terra": true,
|
||||
"gpt-6-astra": true,
|
||||
"inclusionai/ling-3.0-flash-sante:free": true,
|
||||
"meituan/LongCat-2.0:free": true,
|
||||
"meta/muse-spark-1.1": true,
|
||||
"meta/muse-spark-1.2": true,
|
||||
"meta/muse-spark-1.2-contributor": true,
|
||||
"meta/muse-spark-1.3": true,
|
||||
"meta/muse-spark-1.3-contributor": true,
|
||||
"MiniMaxAI/MiniMax-M3": true,
|
||||
"moonshotai/Kimi-K2.7-Code": true,
|
||||
"moonshotai/Kimi-K2.7-Code-Highspeed": true,
|
||||
@@ -94,6 +108,7 @@ export const MODEL_REASONING: Readonly<Record<string, true>> = {
|
||||
"Qwen/Qwen3.8-27B": true,
|
||||
"Qwen/Qwen3.8-Flash": true,
|
||||
"Qwen/Qwen3.8-Max": true,
|
||||
"Qwen/Qwen3.8-Max-0902": true,
|
||||
"sakana/fugu-ultra": true,
|
||||
"stepfun/Step-3.5-Flash": true,
|
||||
"stepfun/Step-3.7-Flash": true,
|
||||
@@ -120,11 +135,13 @@ export const MODEL_EFFORTS: Readonly<Record<string, readonly CommandCodeReasonin
|
||||
"deepseek/deepseek-v4-flash-fast": ["low", "high", "max"],
|
||||
"deepseek/deepseek-v4-flash-vision-exp": ["high", "max"],
|
||||
"deepseek/deepseek-v4-pro": ["high", "max"],
|
||||
"deepseek/deepseek-v4.1-flash": ["low", "high", "max"],
|
||||
"google/gemini-3.1-flash-lite": ["low", "medium", "high"],
|
||||
"google/gemini-3.5-flash": ["low", "medium", "high"],
|
||||
"google/gemini-3.5-flash-lite": ["low", "medium", "high"],
|
||||
"google/gemini-3.6-flash": ["low", "medium", "high"],
|
||||
"google/gemini-3.7-flash": ["low", "medium", "high"],
|
||||
"google/gemini-3.8-flash": ["low", "medium", "high"],
|
||||
"gpt-5.3-codex": ["low", "medium", "high", "xhigh"],
|
||||
"gpt-5.4": ["low", "medium", "high", "xhigh"],
|
||||
"gpt-5.4-mini": ["low", "medium", "high"],
|
||||
@@ -132,10 +149,18 @@ export const MODEL_EFFORTS: Readonly<Record<string, readonly CommandCodeReasonin
|
||||
"gpt-5.6-luna": ["low", "medium", "high", "xhigh", "max"],
|
||||
"gpt-5.6-sol": ["low", "medium", "high", "xhigh", "max"],
|
||||
"gpt-5.6-terra": ["low", "medium", "high", "xhigh", "max"],
|
||||
"gpt-6-astra": ["low", "medium", "high", "xhigh", "max"],
|
||||
"meta/muse-spark-1.1": ["low", "medium", "high", "xhigh"],
|
||||
"meta/muse-spark-1.2": ["low", "medium", "high", "xhigh"],
|
||||
"meta/muse-spark-1.2-contributor": ["low", "medium", "high", "xhigh"],
|
||||
"meta/muse-spark-1.3": ["low", "medium", "high", "xhigh", "max"],
|
||||
"meta/muse-spark-1.3-contributor": ["low", "medium", "high", "xhigh"],
|
||||
"MiniMaxAI/MiniMax-M3": ["low", "medium", "high"],
|
||||
"moonshotai/Kimi-K3": ["low", "high", "max"],
|
||||
"Qwen/Qwen3.8-27B": ["low", "medium", "xhigh"],
|
||||
"Qwen/Qwen3.8-Flash": ["low", "medium", "xhigh"],
|
||||
"Qwen/Qwen3.8-Max": ["low", "medium", "xhigh"],
|
||||
"Qwen/Qwen3.8-Max-0902": ["low", "medium", "xhigh"],
|
||||
"sakana/fugu-ultra": ["high", "xhigh"],
|
||||
"tencent/hy4-preview": ["low", "medium", "high"],
|
||||
"xai/grok-4.5": ["low", "medium", "high"],
|
||||
@@ -146,6 +171,7 @@ export const MODEL_EFFORTS: Readonly<Record<string, readonly CommandCodeReasonin
|
||||
}
|
||||
|
||||
export const MODEL_MAX_OUTPUT_TOKENS: Readonly<Record<string, number>> = {
|
||||
"inclusionai/ling-3.0-flash-sante:free": 32_768,
|
||||
"poolside/laguna-s-2.1-free": 32_768,
|
||||
"Qwen/Qwen3.8-27B": 32_768,
|
||||
"z-ai/glm-5.3-flash": 131_072,
|
||||
|
||||
+21
-4
@@ -147,6 +147,13 @@ export const COMMAND_CODE_PLACEHOLDER_KEYS = new Set([
|
||||
"COMMANDCODE_API_KEY",
|
||||
])
|
||||
|
||||
function usableCommandCodeApiKey(value: string | undefined): string | undefined {
|
||||
const trimmed = typeof value === "string" ? value.trim() : undefined
|
||||
if (!trimmed) return undefined
|
||||
if (COMMAND_CODE_PLACEHOLDER_KEYS.has(trimmed)) return undefined
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the real API key from a host registry value and/or the env/auth-file
|
||||
* fallback, never returning a literal placeholder or an empty/whitespace value.
|
||||
@@ -156,10 +163,20 @@ export function pickCommandCodeApiKey(
|
||||
registryKey: string | undefined,
|
||||
hostKey: string | undefined,
|
||||
): string | undefined {
|
||||
const trimmed = typeof registryKey === "string" ? registryKey.trim() : undefined
|
||||
if (!trimmed) return hostKey
|
||||
if (COMMAND_CODE_PLACEHOLDER_KEYS.has(trimmed)) return hostKey
|
||||
return trimmed
|
||||
return usableCommandCodeApiKey(registryKey) ?? usableCommandCodeApiKey(hostKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a host-supplied placeholder (or missing key) with the configured
|
||||
* fallback. Used for both registerProvider and the Provider API stream path.
|
||||
*/
|
||||
export function withResolvedCommandCodeApiKey<T extends { apiKey?: string }>(
|
||||
options: T | undefined,
|
||||
configuredKey: string | undefined,
|
||||
): T | { apiKey?: string } {
|
||||
const apiKey = pickCommandCodeApiKey(options?.apiKey, configuredKey)
|
||||
if (options && apiKey === options.apiKey) return options
|
||||
return { ...options, apiKey }
|
||||
}
|
||||
|
||||
export function textContent(message: { content?: unknown }): string {
|
||||
|
||||
+5
-12
@@ -19,6 +19,7 @@ import {
|
||||
messagesToCC,
|
||||
numberValue,
|
||||
parseStreamEventLine,
|
||||
pickCommandCodeApiKey,
|
||||
recordOrEmpty,
|
||||
stringValue,
|
||||
toolsToJson,
|
||||
@@ -237,22 +238,14 @@ export function createStreamCommandCode(deps: CoreDependencies) {
|
||||
|
||||
async function run() {
|
||||
// Some hosts pass a literal env-var reference instead of resolving it.
|
||||
const PLACEHOLDER_API_KEYS = new Set([
|
||||
"$COMMAND_CODE_API_KEY",
|
||||
"COMMAND_CODE_API_KEY",
|
||||
"$COMMANDCODE_API_KEY",
|
||||
"COMMANDCODE_API_KEY",
|
||||
])
|
||||
const hostKey =
|
||||
options?.apiKey && !PLACEHOLDER_API_KEYS.has(options.apiKey) ? options.apiKey : undefined
|
||||
|
||||
const apiKey =
|
||||
hostKey ??
|
||||
const apiKey = pickCommandCodeApiKey(
|
||||
options?.apiKey,
|
||||
getApiKey({
|
||||
env: deps.env,
|
||||
authPaths: deps.authPaths,
|
||||
homeDir: deps.homeDir,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
if (!apiKey) {
|
||||
const msg: AssistantMessageLike = {
|
||||
|
||||
+16
-1
@@ -126,6 +126,22 @@ describe("commandCodeModelsFromApiResponse()", () => {
|
||||
assert.equal(modelSupportsImageInput("unknown-new-model"), false)
|
||||
})
|
||||
|
||||
it("exposes DeepSeek V4.1 vision input and its low/high/max thinking levels", () => {
|
||||
const modelId = "deepseek/deepseek-v4.1-flash"
|
||||
assert.deepEqual(inputModalitiesForModel(modelId), ["text", "image"])
|
||||
assert.equal(modelSupportsImageInput(modelId), true)
|
||||
assert.equal(MODEL_REASONING[modelId], true)
|
||||
assert.deepEqual(MODEL_EFFORTS[modelId], ["low", "high", "max"])
|
||||
assert.deepEqual(thinkingMetadataForModel(modelId)?.thinkingLevelMap, {
|
||||
minimal: null,
|
||||
low: "low",
|
||||
medium: null,
|
||||
high: "high",
|
||||
xhigh: null,
|
||||
max: "max",
|
||||
})
|
||||
})
|
||||
|
||||
it("tracks reasoning independently from selectable effort levels", () => {
|
||||
const reasoningModels = Object.keys(MODEL_REASONING)
|
||||
const effortModels = Object.keys(MODEL_EFFORTS)
|
||||
@@ -198,7 +214,6 @@ describe("commandCodeModelsFromApiResponse()", () => {
|
||||
|
||||
it("merges manual effort overrides over the generated catalog", () => {
|
||||
const validEfforts = new Set(["minimal", "low", "medium", "high", "xhigh", "max"])
|
||||
assert.ok(Object.keys(MODEL_EFFORT_OVERRIDES).length > 0)
|
||||
for (const [modelId, efforts] of Object.entries(MODEL_EFFORT_OVERRIDES)) {
|
||||
assert.equal(MODEL_REASONING[modelId], true, `${modelId} override needs a reasoning flag`)
|
||||
assert.equal(
|
||||
|
||||
+141
-8
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
import { spawn } from "node:child_process"
|
||||
import { spawn, spawnSync } from "node:child_process"
|
||||
import { accessSync, constants, mkdtempSync, rmSync } from "node:fs"
|
||||
import { createServer } from "node:http"
|
||||
import { tmpdir } from "node:os"
|
||||
@@ -176,19 +176,93 @@ const address = server.address()
|
||||
const port = typeof address === "object" && address ? address.port : 0
|
||||
const apiBase = `http://127.0.0.1:${port}`
|
||||
|
||||
function runOmp(args, timeoutMs = 30_000) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(OMP_BIN, args, {
|
||||
cwd: PROJECT_DIR,
|
||||
env: {
|
||||
const agentDir = join(tempHome, ".omp", "agent")
|
||||
|
||||
function ompEnv(overrides = {}) {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: tempHome,
|
||||
USERPROFILE: tempHome,
|
||||
PI_CODING_AGENT_DIR: join(tempHome, ".omp", "agent"),
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
COMMAND_CODE_API_KEY: "mock-key",
|
||||
COMMANDCODE_API_BASE: `${apiBase}/provider/v1`,
|
||||
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
|
||||
},
|
||||
}
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (value === undefined) delete env[key]
|
||||
else env[key] = value
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// Same DDL OMP 18 runs for its credential store; OMP's own migration is
|
||||
// `CREATE TABLE IF NOT EXISTS`, so creating it first is safe.
|
||||
const OMP_AUTH_CREDENTIALS_DDL = `CREATE TABLE IF NOT EXISTS auth_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
disabled_cause TEXT DEFAULT NULL,
|
||||
identity_key TEXT DEFAULT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)),
|
||||
updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))
|
||||
)`
|
||||
|
||||
/**
|
||||
* Store a credential the way OMP's `/login` does: in the `auth_credentials`
|
||||
* table of `agent.db`. OMP is a Bun binary, so `bun:sqlite` is always
|
||||
* available next to it and matches the SQLite build OMP itself uses.
|
||||
* Pass `undefined` to remove any stored Command Code credential.
|
||||
*/
|
||||
function seedOmpCredential(credential) {
|
||||
const rows =
|
||||
credential === undefined
|
||||
? []
|
||||
: [
|
||||
[
|
||||
credential.type,
|
||||
JSON.stringify(
|
||||
credential.type === "oauth"
|
||||
? {
|
||||
access: credential.access,
|
||||
refresh: credential.refresh,
|
||||
expires: credential.expires,
|
||||
}
|
||||
: { key: credential.key, source: "login" },
|
||||
),
|
||||
],
|
||||
]
|
||||
const script = `
|
||||
import { Database } from "bun:sqlite"
|
||||
// \`bun -e\` has no script slot: argv is [bun, ...args].
|
||||
const [dbPath, ddl, rowsJson] = process.argv.slice(1)
|
||||
const db = new Database(dbPath)
|
||||
db.run(ddl)
|
||||
db.run("DELETE FROM auth_credentials WHERE provider = ?", ["commandcode"])
|
||||
for (const [type, data] of JSON.parse(rowsJson)) {
|
||||
db.run(
|
||||
"INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?)",
|
||||
["commandcode", type, data],
|
||||
)
|
||||
}
|
||||
db.close()
|
||||
`
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["-e", script, join(agentDir, "agent.db"), OMP_AUTH_CREDENTIALS_DDL, JSON.stringify(rows)],
|
||||
{ env: ompEnv(), encoding: "utf-8" },
|
||||
)
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
}
|
||||
|
||||
function runOmp(args, timeoutOrOptions = 30_000) {
|
||||
const options =
|
||||
typeof timeoutOrOptions === "number" ? { timeoutMs: timeoutOrOptions } : timeoutOrOptions
|
||||
const timeoutMs = options.timeoutMs ?? 30_000
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(OMP_BIN, args, {
|
||||
cwd: PROJECT_DIR,
|
||||
env: ompEnv(options.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
let stdout = ""
|
||||
@@ -269,6 +343,65 @@ try {
|
||||
assert.equal(lastRequestBody?.model, TEST_MODEL)
|
||||
assert.ok(Array.isArray(lastRequestBody?.messages))
|
||||
|
||||
// OMP stores `/login` credentials in agent.db and consults them only when
|
||||
// the extension does not install a config API key. main registered the
|
||||
// unresolved `$COMMAND_CODE_API_KEY` placeholder, which OMP kept as a
|
||||
// literal config override and sent as the Bearer token, so stored
|
||||
// credentials never reached the request (401).
|
||||
const chatArgs = ["-e", EXT_PATH, "-p", "say mock token", "--model", `commandcode/${TEST_MODEL}`]
|
||||
const noEnvKey = { COMMAND_CODE_API_KEY: undefined, COMMANDCODE_API_KEY: undefined }
|
||||
|
||||
console.log("[omp-compat] stored /login OAuth credential is used when no env key exists")
|
||||
seedOmpCredential({
|
||||
type: "oauth",
|
||||
access: "stored-oauth-token",
|
||||
refresh: "stored-oauth-token",
|
||||
expires: Date.now() + 24 * 60 * 60 * 1000,
|
||||
})
|
||||
requestCount = 0
|
||||
lastRequestHeaders = {}
|
||||
const oauthChat = await runOmp(chatArgs, { env: noEnvKey })
|
||||
assert.equal(oauthChat.code, 0, oauthChat.stderr)
|
||||
assert.match(oauthChat.stdout, /mock-omp-ok/)
|
||||
assert.equal(requestCount, 1)
|
||||
assert.equal(lastRequestHeaders.authorization, "Bearer stored-oauth-token")
|
||||
|
||||
console.log("[omp-compat] stored /login API key credential is used when no env key exists")
|
||||
seedOmpCredential({ type: "api_key", key: "stored-api-key" })
|
||||
requestCount = 0
|
||||
lastRequestHeaders = {}
|
||||
const apiKeyChat = await runOmp(chatArgs, { env: noEnvKey })
|
||||
assert.equal(apiKeyChat.code, 0, apiKeyChat.stderr)
|
||||
assert.match(apiKeyChat.stdout, /mock-omp-ok/)
|
||||
assert.equal(requestCount, 1)
|
||||
assert.equal(lastRequestHeaders.authorization, "Bearer stored-api-key")
|
||||
|
||||
console.log("[omp-compat] --api-key wins over a stored credential")
|
||||
requestCount = 0
|
||||
lastRequestHeaders = {}
|
||||
const cliKeyChat = await runOmp([...chatArgs, "--api-key", "cli-key"], { env: noEnvKey })
|
||||
assert.equal(cliKeyChat.code, 0, cliKeyChat.stderr)
|
||||
assert.match(cliKeyChat.stdout, /mock-omp-ok/)
|
||||
assert.equal(requestCount, 1)
|
||||
assert.equal(lastRequestHeaders.authorization, "Bearer cli-key")
|
||||
|
||||
console.log("[omp-compat] COMMAND_CODE_API_KEY still works alongside a stored credential")
|
||||
requestCount = 0
|
||||
lastRequestHeaders = {}
|
||||
const envKeyChat = await runOmp(chatArgs)
|
||||
assert.equal(envKeyChat.code, 0, envKeyChat.stderr)
|
||||
assert.match(envKeyChat.stdout, /mock-omp-ok/)
|
||||
assert.equal(requestCount, 1)
|
||||
assert.match(lastRequestHeaders.authorization ?? "", /^Bearer (mock-key|stored-api-key)$/)
|
||||
|
||||
console.log("[omp-compat] no credential at all never sends the placeholder")
|
||||
seedOmpCredential(undefined)
|
||||
requestCount = 0
|
||||
lastRequestHeaders = {}
|
||||
const noKeyChat = await runOmp(chatArgs, { env: noEnvKey })
|
||||
assert.equal(requestCount, 0, JSON.stringify(lastRequestHeaders))
|
||||
assert.doesNotMatch(noKeyChat.stdout + noKeyChat.stderr, /\$COMMAND_CODE_API_KEY/)
|
||||
|
||||
console.log("[omp-compat] developer advisory reaches the legacy generate request body")
|
||||
requestCount = 0
|
||||
requestBodies = []
|
||||
|
||||
+109
-10
@@ -44,12 +44,20 @@ function findPiBinary() {
|
||||
|
||||
const PI_BIN = findPiBinary()
|
||||
if (!PI_BIN) {
|
||||
if (process.env.PI_LOCAL_REQUIRED === "1") {
|
||||
console.error("[pi-local] FAIL - pi is required but not on PATH and PI_BIN is unset")
|
||||
process.exit(1)
|
||||
}
|
||||
console.log("[pi-local] SKIP — pi is not on PATH")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const piCheck = spawnSync(PI_BIN, ["--help"], { stdio: "ignore" })
|
||||
if (piCheck.error) {
|
||||
if (process.env.PI_LOCAL_REQUIRED === "1") {
|
||||
console.error(`[pi-local] FAIL - pi failed to start: ${piCheck.error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`[pi-local] SKIP — pi failed to start: ${piCheck.error.message}`)
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -226,11 +234,19 @@ const env = {
|
||||
COMMANDCODE_MODELS_URL: `${apiBase}/provider/v1/models`,
|
||||
}
|
||||
|
||||
function runPi(args, timeoutMs = 30_000) {
|
||||
function runPi(args, timeoutOrOptions = 30_000) {
|
||||
const options =
|
||||
typeof timeoutOrOptions === "number" ? { timeoutMs: timeoutOrOptions } : timeoutOrOptions
|
||||
const timeoutMs = options.timeoutMs ?? 30_000
|
||||
const childEnv = { ...env }
|
||||
for (const [key, value] of Object.entries(options.env ?? {})) {
|
||||
if (value === undefined) delete childEnv[key]
|
||||
else childEnv[key] = value
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(PI_BIN, args, {
|
||||
cwd: PROJECT_DIR,
|
||||
env,
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
let stdout = ""
|
||||
@@ -421,9 +437,9 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
|
||||
stderr += chunk.toString("utf-8")
|
||||
})
|
||||
|
||||
const waitFor = (predicate) =>
|
||||
const waitFor = (predicate, fromIndex = 0) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const existing = events.find(predicate)
|
||||
const existing = events.slice(fromIndex).find(predicate)
|
||||
if (existing) {
|
||||
resolve(existing)
|
||||
return
|
||||
@@ -445,17 +461,27 @@ async function runRpcExtensionCommands(timeoutMs = 30_000) {
|
||||
)
|
||||
const commandNames = commandsResponse.data?.commands?.map((command) => command.name) ?? []
|
||||
|
||||
send({ id: "status-before", type: "prompt", message: "/commandcode-status" })
|
||||
await waitFor(
|
||||
(event) => event.type === "response" && event.id === "status-before" && event.success,
|
||||
)
|
||||
const statusBefore = await waitFor(
|
||||
// The cached catalog registers immediately and refreshes in the
|
||||
// background. `/commandcode-refresh` coalesces with an in-flight refresh,
|
||||
// so the status must report the startup refresh as finished before the
|
||||
// catalog is changed; otherwise the command reports the old catalog.
|
||||
let statusBefore
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const id = `status-before-${attempt}`
|
||||
const fromIndex = events.length
|
||||
send({ id, type: "prompt", message: "/commandcode-status" })
|
||||
await waitFor((event) => event.type === "response" && event.id === id && event.success)
|
||||
statusBefore = await waitFor(
|
||||
(event) =>
|
||||
event.type === "extension_ui_request" &&
|
||||
event.method === "notify" &&
|
||||
typeof event.message === "string" &&
|
||||
event.message.includes("model count: 3"),
|
||||
fromIndex,
|
||||
)
|
||||
if (/source: live[\s\S]*refresh: idle/.test(statusBefore.message)) break
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
includeRefreshedModel = true
|
||||
send({ id: "refresh", type: "prompt", message: "/commandcode-refresh" })
|
||||
@@ -713,7 +739,11 @@ try {
|
||||
const offlineListOutput = offlineList.stdout || offlineList.stderr
|
||||
assert.match(offlineListOutput, /gpt-5\.4/)
|
||||
assert.match(offlineListOutput, /cc-second-model/)
|
||||
assert.match(offlineList.stderr, /Using the cached catalog/)
|
||||
// The cached catalog is registered before the background refresh fails, and
|
||||
// `--list-models` exits as soon as the list is printed. Whether the refresh
|
||||
// warning reaches stderr first depends on the host runtime (Bun flushes it,
|
||||
// Node does not), so the warning is asserted on the print run below, which
|
||||
// waits for the response.
|
||||
|
||||
console.log("[pi-local] use a cached model while model discovery is offline")
|
||||
requestCount = 0
|
||||
@@ -824,6 +854,75 @@ try {
|
||||
"string",
|
||||
)
|
||||
|
||||
// pi resolves `/login` credentials, `--api-key`, and env keys through the
|
||||
// provider's registered auth methods. Stored credentials and `--api-key`
|
||||
// only reach the request when the provider keeps an API-key auth method
|
||||
// next to OAuth, so every credential source is checked without an env key.
|
||||
const authArgs = [
|
||||
"--no-extensions",
|
||||
"-e",
|
||||
EXT_PATH,
|
||||
"-p",
|
||||
"say mock token",
|
||||
"--provider",
|
||||
"commandcode",
|
||||
"--model",
|
||||
TEST_MODEL,
|
||||
]
|
||||
const noEnvKey = { COMMAND_CODE_API_KEY: undefined, COMMANDCODE_API_KEY: undefined }
|
||||
const authPath = join(agentDir, "auth.json")
|
||||
|
||||
console.log("[pi-local] stored /login OAuth credential is used when no env key exists")
|
||||
writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
commandcode: {
|
||||
type: "oauth",
|
||||
access: "stored-oauth-token",
|
||||
refresh: "stored-oauth-token",
|
||||
expires: Date.now() + 24 * 60 * 60 * 1000,
|
||||
},
|
||||
}),
|
||||
)
|
||||
requestCount = 0
|
||||
lastRequestHeaders = {}
|
||||
const oauthPrint = await runPi(authArgs, { env: noEnvKey })
|
||||
assert.equal(oauthPrint.code, 0, oauthPrint.stderr)
|
||||
assert.match(oauthPrint.stdout, /mock-pi-ok/)
|
||||
assert.equal(requestCount, 1)
|
||||
assert.equal(lastRequestHeaders.authorization, "Bearer stored-oauth-token")
|
||||
|
||||
console.log("[pi-local] stored /login API key credential is used when no env key exists")
|
||||
writeFileSync(
|
||||
authPath,
|
||||
JSON.stringify({ commandcode: { type: "api_key", key: "stored-api-key" } }),
|
||||
)
|
||||
requestCount = 0
|
||||
lastRequestHeaders = {}
|
||||
const apiKeyPrint = await runPi(authArgs, { env: noEnvKey })
|
||||
assert.equal(apiKeyPrint.code, 0, apiKeyPrint.stderr)
|
||||
assert.match(apiKeyPrint.stdout, /mock-pi-ok/)
|
||||
assert.equal(requestCount, 1)
|
||||
assert.equal(lastRequestHeaders.authorization, "Bearer stored-api-key")
|
||||
|
||||
console.log("[pi-local] --api-key is used when no env key or stored credential exists")
|
||||
rmSync(authPath, { force: true })
|
||||
requestCount = 0
|
||||
lastRequestHeaders = {}
|
||||
const cliKeyPrint = await runPi([...authArgs, "--api-key", "cli-key"], { env: noEnvKey })
|
||||
assert.equal(cliKeyPrint.code, 0, cliKeyPrint.stderr)
|
||||
assert.match(cliKeyPrint.stdout, /mock-pi-ok/)
|
||||
assert.equal(requestCount, 1)
|
||||
assert.equal(lastRequestHeaders.authorization, "Bearer cli-key")
|
||||
|
||||
console.log("[pi-local] no credential at all never sends the placeholder")
|
||||
requestCount = 0
|
||||
lastRequestHeaders = {}
|
||||
const noKeyPrint = await runPi(authArgs, { env: noEnvKey })
|
||||
assert.notEqual(noKeyPrint.code, 0)
|
||||
assert.equal(requestCount, 0, JSON.stringify(lastRequestHeaders))
|
||||
assert.doesNotMatch(noKeyPrint.stdout + noKeyPrint.stderr, /\$COMMAND_CODE_API_KEY/)
|
||||
|
||||
console.log("[pi-local] Claude request through Anthropic Messages endpoint")
|
||||
requestCount = 0
|
||||
const claudePrint = await runPi(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
messagesToCC,
|
||||
parseStreamEventLine,
|
||||
pickCommandCodeApiKey,
|
||||
withResolvedCommandCodeApiKey,
|
||||
projectSlugFromPath,
|
||||
textContent,
|
||||
toJsonSchema,
|
||||
@@ -148,6 +149,42 @@ describe("pickCommandCodeApiKey()", () => {
|
||||
it("trims a real registry key", () => {
|
||||
assert.equal(pickCommandCodeApiKey(" real-registry-key ", "file-key"), "real-registry-key")
|
||||
})
|
||||
|
||||
it("never returns a placeholder as the host fallback", () => {
|
||||
assert.equal(pickCommandCodeApiKey(undefined, "$COMMAND_CODE_API_KEY"), undefined)
|
||||
assert.equal(pickCommandCodeApiKey("$COMMAND_CODE_API_KEY", "$COMMANDCODE_API_KEY"), undefined)
|
||||
assert.equal(pickCommandCodeApiKey("COMMAND_CODE_API_KEY", "COMMANDCODE_API_KEY"), undefined)
|
||||
})
|
||||
|
||||
it("omits a placeholder from registerProvider when no real key is configured", () => {
|
||||
assert.equal(pickCommandCodeApiKey(undefined, undefined), undefined)
|
||||
assert.equal(pickCommandCodeApiKey("$COMMAND_CODE_API_KEY", undefined), undefined)
|
||||
assert.equal(pickCommandCodeApiKey("user_real-key", undefined), "user_real-key")
|
||||
})
|
||||
})
|
||||
|
||||
describe("withResolvedCommandCodeApiKey()", () => {
|
||||
it("replaces a host placeholder with the configured key", () => {
|
||||
assert.deepEqual(
|
||||
withResolvedCommandCodeApiKey({ apiKey: "$COMMAND_CODE_API_KEY", extra: true }, "file-key"),
|
||||
{ apiKey: "file-key", extra: true },
|
||||
)
|
||||
})
|
||||
|
||||
it("drops a placeholder when no configured key exists", () => {
|
||||
assert.deepEqual(
|
||||
withResolvedCommandCodeApiKey({ apiKey: "$COMMAND_CODE_API_KEY" }, undefined),
|
||||
{
|
||||
apiKey: undefined,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps a real host key and injects a configured key when the host omitted one", () => {
|
||||
const options = { apiKey: "host-key" }
|
||||
assert.equal(withResolvedCommandCodeApiKey(options, "file-key"), options)
|
||||
assert.deepEqual(withResolvedCommandCodeApiKey(undefined, "file-key"), { apiKey: "file-key" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("projectSlugFromPath()", () => {
|
||||
|
||||
@@ -108,6 +108,21 @@ describe("streamCommandCode — auth", () => {
|
||||
|
||||
assert.equal(server.lastRequestHeaders().authorization, "Bearer option-key")
|
||||
})
|
||||
|
||||
it("treats a blank options.apiKey like a missing one", async () => {
|
||||
server.mockResponse({
|
||||
type: "success",
|
||||
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
|
||||
})
|
||||
const { streamCommandCode } = createTestDeps({
|
||||
apiBase: server.baseUrl(),
|
||||
env: { COMMAND_CODE_API_KEY: "env-key" },
|
||||
})
|
||||
|
||||
await collectEvents(streamCommandCode(makeModel(), makeContext(), { apiKey: " " }))
|
||||
|
||||
assert.equal(server.lastRequestHeaders().authorization, "Bearer env-key")
|
||||
})
|
||||
})
|
||||
|
||||
describe("streamCommandCode — successful streams", () => {
|
||||
|
||||
Reference in New Issue
Block a user