From e1abed9b4425914ac54a8148888acf7f2964a899 Mon Sep 17 00:00:00 2001 From: TaewoooPark Date: Thu, 15 Oct 2026 11:36:02 +0900 Subject: [PATCH] feat(cli): add Morphfactory provider selection --- .env.example | 6 ++ README.ko.md | 63 ++++++------ README.md | 67 ++++++------ packages/cli/README.md | 25 +++-- packages/cli/src/chat.ts | 148 ++++++++++++++++++++++++--- packages/cli/src/commands.ts | 15 ++- packages/cli/src/config-controls.ts | 3 +- packages/cli/src/doctor.ts | 8 +- packages/cli/src/login.ts | 15 ++- packages/cli/src/main.ts | 47 +++++++-- packages/cli/test/dashboard.test.ts | 112 +++++++++++++++++++- packages/cli/test/e2e.test.ts | 49 ++++++++- packages/cli/test/login.test.ts | 14 ++- packages/core/src/config.ts | 52 ++++++++-- packages/core/src/transport.ts | 7 +- packages/core/test/config.test.ts | 71 ++++++++++++- packages/core/test/transport.test.ts | 13 +++ 17 files changed, 579 insertions(+), 136 deletions(-) diff --git a/.env.example b/.env.example index 3fb6aac..9db42c6 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,9 @@ MOTIF_ENDPOINT=https://llm.onerouter.pro/v1 MOTIF_MODEL=motif/motif-3 MOTIF_API_KEY=sk-... + +# Morphfactory (BETA): choose /provider in the session or use --provider morphfactory. +# When selecting through this file, replace MOTIF_ENDPOINT above with: +# MOTIF_ENDPOINT=https://api-cbt.morphfactory.io/v1 +# Keep each provider's key separate; changing providers preserves the other key. +# MOTIF_MORPHFACTORY_API_KEY=your-morphfactory-key diff --git a/README.ko.md b/README.ko.md index dc999f6..484be20 100644 --- a/README.ko.md +++ b/README.ko.md @@ -30,11 +30,9 @@ OpenLab Discord 참여하기

-> **2026년 9월까지 무료.** Motif-3는 [Infron](https://infron.ai)에서 **Motif: Motif 3 (Free)** 로 -> 제공됩니다. 입력·출력 모두 100만 토큰당 $0, 262,144 토큰 컨텍스트 전체가 열려 있고, -> 2026년 9월 말까지 무료 제공이 공지되어 있습니다. 계정과 API 키만 있으면 됩니다. -> `npx motifcode`가 키를 물어본 뒤 `motif` 명령까지 설치해 줍니다. -> 조건은 바뀔 수 있으며, [모델 페이지](https://infron.ai/models/motif/motif-3)가 기준입니다. +> **Provider를 선택하세요.** 처음 실행하면 [Infron](https://infron.ai) 또는 +> [Morphfactory (BETA)](https://cbt.morphfactory.io/console?view=keys)를 선택할 수 있습니다. +> 이후에는 `/provider`나 `/config`에서 바꾸며, 각 provider의 API 키는 따로 보관합니다. > **Motif 공식 프로젝트가 아닙니다.** Motifcode는 독립적인 오픈소스 프로젝트입니다. > 모티프테크놀로지스(Motif Technologies)나 Infron이 인증·보증·후원·관리하는 저장소가 @@ -98,7 +96,7 @@ Motifcode는 Claude Code의 모양을 한 터미널 코딩 에이전트입니다 첫 실행에서 API 키를 묻는 화면
- 첫 실행. 프롬프트 자리에서 Infron API 키를 물어보고, 확인한 뒤 한 번만 저장합니다. + 첫 실행. Provider를 선택하고 API 키를 입력하면, 확인한 뒤 한 번만 저장합니다. /skills 목록
@@ -124,7 +122,7 @@ cd your-project npx motifcode # 첫 실행: 키를 물어본 뒤 `motif` 명령 설치까지 해 줍니다 ``` -첫 세션에서 Infron API 키를 한 번만 물어보고 `~/.motif/.env`에 저장합니다. `npx`는 +첫 세션에서 **Infron** 또는 **Morphfactory (BETA)** 를 선택하고, API 키를 한 번만 입력해 `~/.motif/.env`에 저장합니다. `npx`는 명령을 남기지 않으므로 이어서 `npm install -g motifcode`를 대신 실행해 줄지 물어봅니다. 예라고 하면 그다음부터는 어느 폴더에서든 `motif`(또는 `motifcode`)로 세션을 엽니다. 처음부터 `npm install -g motifcode`를 직접 해도 같은 결과입니다. @@ -388,32 +386,32 @@ motif plugins connect library-docs --- -## Infron에서 API 키 발급하기 +## Provider 선택과 API 키 발급 -Motif-3는 Infron의 OpenAI 호환 엔드포인트로 접근합니다. 설정 전체는 base URL, 모델 -id, 키 세 가지입니다. +처음 실행하면 Infron 또는 Morphfactory (BETA)를 선택합니다. 기존 사용자의 연결은 +유지되며, `/provider`나 `/config`의 provider 항목에서 바꿀 수 있습니다. 두 provider +모두 `motif/motif-3` 모델과 OpenAI 호환 엔드포인트를 사용합니다. -| | | -|---|---| -| base URL | `https://llm.onerouter.pro/v1` | -| 모델 | `motif/motif-3` | -| 키 | `MOTIF_API_KEY` | - -1. **[infron.ai/login](https://infron.ai/login)** 에서 이메일이나 Google로 로그인합니다. -2. **[Dashboard → API Keys](https://infron.ai/dashboard/apiKeys)** 를 열고 **Add new key** 를 누릅니다. -3. `motif`를 실행하고, 물어볼 때 키를 붙여 넣습니다. 토큰 하나짜리 요청으로 - 엔드포인트에 확인한 뒤 `~/.motif/.env`에 저장되고(본인만 읽을 수 있는 권한), - 모델에게는 절대 보여 주지 않습니다. 세션 밖에서는 `motif login`이 같은 일을 - 하고, 세션 안에서는 `/login`과 `/logout`이 있습니다. 환경 변수의 - `MOTIF_API_KEY`나 프로젝트 옆의 `.env`도 되고, `--env-file <경로>`는 그 파일을 - 가장 먼저 읽게 합니다. -4. `motif doctor`가 연결을 확인하고, 서버가 도구 호출과 추론을 어떤 형태로 - 돌려주는지와 프리픽스 캐시가 켜져 있는지를 보고합니다. - -**2026년 9월까지 무료.** 이 글을 쓰는 시점에 Infron은 이 모델을 *Motif: Motif 3 -(Free)* 로, 입력·출력 모두 100만 토큰당 $0로 올려 두었고, 2026년 9월 말까지 무료 -제공을 공지했습니다. 현재 조건은 [모델 페이지](https://infron.ai/models/motif/motif-3)와 -Infron의 [무료 모델 약관](https://infron.ai/docs/overview/free-models)에서 확인하세요. +| provider | base URL | API 키 환경 변수 | +|---|---|---| +| Infron | `https://llm.onerouter.pro/v1` | `MOTIF_API_KEY` | +| Morphfactory (BETA) | `https://api-cbt.morphfactory.io/v1` | `MOTIF_MORPHFACTORY_API_KEY` | + +1. [Infron API Keys 대시보드](https://infron.ai/dashboard/apiKeys) 또는 + [Morphfactory 콘솔](https://cbt.morphfactory.io/console?view=keys)에서 키를 발급합니다. +2. `motif`를 실행해 provider를 선택하고 키를 붙여 넣습니다. 토큰 하나짜리 요청으로 + 확인한 뒤 본인만 읽을 수 있는 `~/.motif/.env`에 저장합니다. 키는 따로 보관하므로 + provider를 바꾸거나 `/logout`을 해도 다른 provider의 키는 유지됩니다. +3. 스크립트에서는 `motif login --provider morphfactory`로 로그인하고 + `motif --provider morphfactory -p "질문"`으로 실행합니다. `--provider infron`은 + Infron을 선택하며, 명시한 `--endpoint`와 `--model`이 preset보다 우선합니다. +4. `motif doctor --provider morphfactory`로 연결, 도구 호출, 추론과 프리픽스 캐시를 + 확인합니다. Infron 계정 잔액은 Infron에서만 지원하며, 로컬 세션 토큰 사용량은 + 두 provider 모두 볼 수 있습니다. + +환경 변수나 프로젝트 `.env`도 지원하며 `--env-file <경로>`는 해당 파일을 먼저 읽습니다. +환경 파일에서 provider를 지정하려면 `MOTIF_ENDPOINT`를 설정합니다. 별도 설정이 없으면 +기존과 같이 Infron을 사용합니다. `.env` 파일에서는 `MOTIF_*` 키만 읽고, 어느 것도 환경 변수로 내보내지 않으며, 무엇이든 실행되기 전에 하네스 자신의 환경에서도 키를 지웁니다. 모델의 `bash`도, 프로젝트 훅도 @@ -433,7 +431,8 @@ Infron의 [무료 모델 약관](https://infron.ai/docs/overview/free-models)에 | `/doctor` | 엔드포인트 점검: 인증, 파서, 캐시, 채널 | | `/mcp [list\|connect NAME\|disconnect NAME\|reconnect NAME\|login NAME\|logout NAME]` | MCP 관리 화면에서 프리셋 추가, 연결 상태 조회·제어와 로그인 | | `/mcp-setup ` | 내장 스킬로 MCP 서버 등록과 연결 확인 | -| `/login`, `/logout` | Infron API 키를 붙여 넣어 확인 후 `~/.motif/.env`에 저장; 저장된 키 삭제 | +| `/provider [infron\|morphfactory]` | 각 provider의 키를 유지하며 provider 선택 | +| `/login`, `/logout` | 선택한 provider의 키를 확인해 `~/.motif/.env`에 저장; 해당 키 삭제 | | `/model [id]`, `/endpoint [url]` | 다음 작업에 쓸 모델 id나 엔드포인트를 보거나 바꿈 | | `/channel [toolcall\|object\|raw]` | 행동 채널을 보거나 바꿈; 바꾸면 대화가 새로 시작됨 | | `/max-turns [n]`, `/max-tokens [n\|off]`, `/seed [n\|off]` | 작업당 상한과 샘플링 시드 | diff --git a/README.md b/README.md index ac517ed..fb17a2c 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,9 @@ Join OpenLab on Discord

-> **Free through September 2026.** Motif-3 is served by [Infron](https://infron.ai) as -> **Motif: Motif 3 (Free)** — $0 per million tokens in and out, the full 262,144-token -> window — and free access has been announced through the end of September 2026. -> An account and an API key are all it takes: `npx motifcode` asks for the key, then installs the `motif` command. -> Terms can change, and the [model page](https://infron.ai/models/motif/motif-3) is the source of truth. +> **Choose your provider.** First launch offers [Infron](https://infron.ai) or +> [Morphfactory (BETA)](https://cbt.morphfactory.io/console?view=keys). +> Switch later with `/provider` or from `/config`; each provider keeps its own API key. > **Not an official Motif project.** Motifcode is an independent open-source project. > It is not certified, endorsed, sponsored or maintained by Motif Technologies or by @@ -104,7 +102,7 @@ session checked feature by feature against Claude Code's. the first run asks for the API key
- First run. The Infron API key is asked for in place of the prompt, checked, and saved once. + First run. Choose a provider, then paste an API key to check and save once. the /skills list
@@ -130,8 +128,8 @@ cd your-project npx motifcode # first run: asks for your key, then offers to install the `motif` command ``` -The first session asks for your Infron API key, once, and saves it to -`~/.motif/.env`. Because `npx` leaves no command behind, it then offers to run +The first session lets you choose **Infron** or **Morphfactory (BETA)**, asks for +your API key once, and saves it to `~/.motif/.env`. Because `npx` leaves no command behind, it then offers to run `npm install -g motifcode` for you; say yes and `motif` (or `motifcode`) opens the session from any folder from then on. `npm install -g motifcode` directly does the same without the question. @@ -404,34 +402,32 @@ and a bundle being available does not mean its service is signed in. See --- -## Get an API key from Infron +## Choose a provider and get an API key -Motif-3 is reached over Infron's OpenAI-compatible endpoint. The whole -configuration is a base URL, a model id and a key: +First launch offers Infron and Morphfactory (BETA). Existing sessions keep their +current connection; use `/provider` or the provider row in `/config` to switch. +Both use the model id `motif/motif-3` and an OpenAI-compatible endpoint: -| | | -|---|---| -| base URL | `https://llm.onerouter.pro/v1` | -| model | `motif/motif-3` | -| key | `MOTIF_API_KEY` | - -1. Sign in at **[infron.ai/login](https://infron.ai/login)** (email or Google). -2. Open **[Dashboard → API Keys](https://infron.ai/dashboard/apiKeys)** and click **Add new key**. -3. Run `motif` and paste the key when asked. It is checked against the - endpoint with a one-token request, saved to `~/.motif/.env` (readable only - by you), and never shown to the model. `motif login` does the same outside - a session, and `/login` and `/logout` inside one. A `MOTIF_API_KEY` in the - environment or in a `.env` next to the project works too; `--env-file - ` puts a file first. -4. `motif doctor` confirms the connection and reports how the server returns - tool calls and reasoning and whether prefix caching is on. - -**Free through September 2026.** At the time of writing Infron lists the model -as *Motif: Motif 3 (Free)* at $0 per million tokens for input and output, and -has announced free access through the end of September 2026. Check the -[model page](https://infron.ai/models/motif/motif-3) and Infron's -[free-model terms](https://infron.ai/docs/overview/free-models) for the current -conditions. +| provider | base URL | API key environment variable | +|---|---|---| +| Infron | `https://llm.onerouter.pro/v1` | `MOTIF_API_KEY` | +| Morphfactory (BETA) | `https://api-cbt.morphfactory.io/v1` | `MOTIF_MORPHFACTORY_API_KEY` | + +1. Create a key at [Infron's API Keys dashboard](https://infron.ai/dashboard/apiKeys) + or [Morphfactory's console](https://cbt.morphfactory.io/console?view=keys). +2. Run `motif`, choose the provider and paste its key. A one-token request checks + it before saving it to `~/.motif/.env` (readable only by you). Provider keys + are stored separately, so switching or `/logout` preserves the other key. +3. In a script, use `motif login --provider morphfactory`, then + `motif --provider morphfactory -p "your question"`. `--provider infron` selects + Infron; explicit `--endpoint` and `--model` flags override the preset. +4. `motif doctor --provider morphfactory` checks the connection, tool calls, + reasoning and prefix caching. Infron's account balance is only available + with Infron; local session token usage remains available for both providers. + +Environment variables or a project `.env` work too; `--env-file ` reads +that file first. Set `MOTIF_ENDPOINT` to select a provider through an env file. +Infron remains the default when no provider is configured. Only `MOTIF_*` keys are read from a `.env` file, none of them are exported, and the key is removed from the harness's own environment before anything is @@ -451,7 +447,8 @@ spawned — the agent's `bash` cannot see it, and neither can a project hook. | `/doctor` | probe the endpoint: auth, parsers, cache, channels | | `/mcp [list\|connect NAME\|disconnect NAME\|reconnect NAME\|login NAME\|logout NAME]` | open the MCP manager to add a preset, or inspect, control and sign in to a connection | | `/mcp-setup ` | use the built-in skill to register and check an MCP server | -| `/login`, `/logout` | paste an Infron API key, checked and saved to `~/.motif/.env`; remove the saved key | +| `/provider [infron\|morphfactory]` | choose a provider, preserving each provider's key | +| `/login`, `/logout` | check and save the selected provider's key to `~/.motif/.env`; remove that saved key | | `/model [id]`, `/endpoint [url]` | show or set the model id or endpoint for the next task | | `/channel [toolcall\|object\|raw]` | show or set the action channel; changing it restarts the conversation | | `/max-turns [n]`, `/max-tokens [n\|off]`, `/seed [n\|off]` | per-task ceilings and the sampling seed | diff --git a/packages/cli/README.md b/packages/cli/README.md index 0d67cd7..701240e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5,26 +5,33 @@ A Claude Code-style terminal session — transcript above, prompt below, `/` com a permission prompt, a `.motif/` directory behind it — whose tool set, prompt layout, parser and failure handling follow from what is measurably true about Motif-3. -> **Free through September 2026.** Motif-3 is served by [Infron](https://infron.ai) at $0 per -> million tokens, with free access announced through the end of September 2026. Terms can change; -> the [model page](https://infron.ai/models/motif/motif-3) is the source of truth. +> **Choose your provider.** First launch offers [Infron](https://infron.ai) or +> [Morphfactory (BETA)](https://cbt.morphfactory.io/console?view=keys). +> Switch later with `/provider` or `/config`; each provider keeps its own API key. > **Not an official Motif project.** Not certified, endorsed, sponsored or maintained by Motif > Technologies or by Infron. *Motif* and *Motif-3* are their names; this package is a client of the model. ```bash cd your-project -npx motifcode # asks for your Infron API key once, then offers to install the `motif` command +npx motifcode # choose a provider and enter its API key once, then offers to install the `motif` command ``` Or `npm install -g motifcode` directly; either way `motif` (or `motifcode`) opens the session from any folder afterwards, with the key saved in `~/.motif/.env`. -1. Sign in at [infron.ai/login](https://infron.ai/login), open [Dashboard → API Keys](https://infron.ai/dashboard/apiKeys), click **Add new key**. -2. Run `motif` and paste the key when asked. It is checked against the endpoint, saved to - `~/.motif/.env` (readable only by you), and never shown to the model. `motif login` does the - same outside a session; `/login` and `/logout` inside one. -3. `motif doctor` reports what the endpoint returns: structured tool calls, reasoning, prefix caching. +1. Create an API key at [Infron's dashboard](https://infron.ai/dashboard/apiKeys) or + [Morphfactory's console](https://cbt.morphfactory.io/console?view=keys). +2. Run `motif`, choose the provider and paste its key. It is checked against the endpoint, + saved to `~/.motif/.env` (readable only by you), and never shown to the model. Use + `/provider` to switch, or `/login` and `/logout` to manage the selected provider's key. +3. Outside a session, use `motif login --provider morphfactory` and + `motif doctor --provider morphfactory`. `--provider infron` selects Infron. + +The environment keys are `MOTIF_API_KEY` for Infron and `MOTIF_MORPHFACTORY_API_KEY` +for Morphfactory. Use `--provider morphfactory` for a single invocation, or choose +with `/provider` to save the selection. Explicit `--endpoint` and `--model` flags +still override a provider preset. ```bash motif # interactive session in the current directory diff --git a/packages/cli/src/chat.ts b/packages/cli/src/chat.ts index acb08f3..80aa1b1 100644 --- a/packages/cli/src/chat.ts +++ b/packages/cli/src/chat.ts @@ -28,6 +28,10 @@ import { dirname, join, resolve } from "node:path"; import { AgentRegistry, AgentScheduler, concurrencyFor } from "@motifcode/agents"; import { HttpTransport, + PROVIDERS, + providerForEndpoint, + apiKeyEnvForEndpoint, + type ProviderId, buildCompactedHistory, defaultEnvPath, forgetApiKey, @@ -87,6 +91,10 @@ export interface ChatOptions { channelPolicy: ChannelPolicy; apiKey?: string; apiKeySource?: string; + /** Credentials captured before the CLI removes secrets from its environment. */ + providerCredentials?: Partial>; + /** Offer provider selection on an unconfigured first run. */ + chooseProvider?: boolean; skills: SkillRegistry; agents: AgentRegistry; hooks: HookConfig; @@ -180,7 +188,7 @@ const CTRL_C_WINDOW_MS = 2000; * `/channel` would leave the next task reading a transcript in the wrong * format. Waiting, or interrupting, is the honest answer. */ -const BLOCKED_WHILE_RUNNING = new Set(["new", "clear", "channel", "resume", "cwd", "compact"]); +const BLOCKED_WHILE_RUNNING = new Set(["new", "clear", "channel", "resume", "cwd", "compact", "provider"]); const MENU_ITEMS: MenuItem[] = COMMANDS.map((c) => ({ name: c.name, @@ -232,6 +240,8 @@ export class Chat { private apiKey: string | undefined; private apiKeySource: string | undefined; private apiKeyVerified = false; + private requireKey: boolean; + private readonly providerCredentials: Partial>; private authGeneration = 0; private verifyingLogin: number | null = null; private readonly billing: InfronBilling; @@ -268,6 +278,9 @@ export class Chat { this.projectNotes = opts.projectNotes; this.apiKey = opts.apiKey; this.apiKeySource = opts.apiKeySource; + this.requireKey = opts.requireKey === true; + this.providerCredentials = { ...opts.providerCredentials }; + this.cacheProviderCredential(); this.now = opts.now ?? (() => Date.now()); this.billing = new InfronBilling({ fetchImpl: opts.balanceFetch, @@ -317,7 +330,7 @@ export class Chat { } this.screen.setLabel(this.settings.model); this.screen.setTitle(`motif · ${this.settings.cwd.split("/").pop() ?? this.settings.cwd}`); - if (this.apiKey === undefined && !this.opts.requireKey) { + if (this.apiKey === undefined && !this.requireKey && !this.opts.chooseProvider) { this.screen.append({ kind: "notice", level: "warn", @@ -332,6 +345,13 @@ export class Chat { /** What happens before the first prompt is free: the login, the resumed conversation, the first task. */ private async start(): Promise { + if (this.opts.chooseProvider) { + const choice = await this.chooseProvider(); + if (this.quitting) return; + if (choice === null) return; + const result = this.switchProvider(choice); + if (result.error) { this.screen.append({ kind: "notice", level: "error", text: result.lines.join(" ") }); this.refresh(); return; } + } void this.billing.refresh(); if (this.needsLogin) { const lines = await this.login("startup"); @@ -672,7 +692,7 @@ export class Chat { } private get needsLogin(): boolean { - return this.opts.requireKey === true && this.apiKey === undefined; + return this.requireKey && this.apiKey === undefined; } /** Take over the prompt for one question; resolves with the index chosen, or null on esc. */ @@ -821,15 +841,22 @@ export class Chat { this.invalidateAuthentication(); const generation = this.authGeneration; const endpoint = this.settings.endpoint; + const model = this.settings.model; + const provider = providerForEndpoint(endpoint) ?? "infron"; + const label = PROVIDERS[provider].label; + const keyEnv = apiKeyEnvForEndpoint(endpoint); const title = - reason === "command" ? "Paste your Infron API key" : reason === "task" ? "An API key is needed before the task can run" : "Paste your Infron API key to get started"; + reason === "command" ? `Paste your ${label} API key` : reason === "task" ? "An API key is needed before the task can run" : `Paste your ${label} API key to get started`; const cancelHint = reason === "command" ? "enter to check and save · esc to cancel" : "enter to check and save · esc to skip for now"; for (;;) { - const raw = await this.askSecret(title, loginLines(this.envPath), "key › ", cancelHint); + const raw = await this.askSecret(title, loginLines(this.envPath, undefined, provider), "key › ", cancelHint); if (raw === null) { return reason === "command" ? ["login cancelled"] - : [`no key entered; /login when you have one, or put MOTIF_API_KEY in ${this.envPath}`]; + : [`no key entered; /login when you have one, or put ${keyEnv} in ${this.envPath}`]; + } + if (this.quitting || generation !== this.authGeneration || endpoint !== this.settings.endpoint) { + return ["login cancelled because the session or endpoint changed; /login to try again"]; } const key = normaliseKeyInput(raw); if (key === "") continue; @@ -837,7 +864,7 @@ export class Chat { this.screen.setActivity("Checking the key…"); let result: VerifyResult; try { - result = await (this.opts.verifyKey ?? ((k: string) => verifyApiKey({ endpoint: this.settings.endpoint, model: this.settings.model, apiKey: k })))(key); + result = await (this.opts.verifyKey ?? ((k: string) => verifyApiKey({ endpoint, model, apiKey: k })))(key); } finally { if (this.verifyingLogin === generation) { this.verifyingLogin = null; @@ -855,10 +882,12 @@ export class Chat { this.apiKeyVerified = true; this.syncBilling(); try { - const path = saveApiKey(key, this.envPath); - this.apiKeySource = `${path} (MOTIF_API_KEY)`; + const path = saveApiKey(key, this.envPath, provider); + this.apiKeySource = `${path} (${keyEnv})`; + this.cacheProviderCredential(); } catch (err) { this.apiKeySource = "this session only"; + this.cacheProviderCredential(); return [`the key works, but ${this.envPath} could not be written: ${err instanceof Error ? err.message : String(err)}`, "it is kept for this session only"]; } return [`signed in · the key is saved to ${this.envPath}`]; @@ -869,9 +898,10 @@ export class Chat { this.invalidateAuthentication(); const source = this.apiKeySource; const had = this.apiKey !== undefined; - const removed = forgetApiKey(this.envPath); + const removed = forgetApiKey(this.envPath, providerForEndpoint(this.settings.endpoint) ?? "infron"); this.apiKey = undefined; this.apiKeySource = undefined; + this.cacheProviderCredential(); this.apiKeyVerified = false; this.billing.configure(this.settings.endpoint); if (!had && !removed) return ["no key is set"]; @@ -1260,9 +1290,7 @@ export class Chat { this.refresh(); return; } - const previousEndpoint = this.settings.endpoint; const out = await runSlash(text, this.context()); - if (previousEndpoint !== this.settings.endpoint) { this.apiKeyVerified = false; this.invalidateAuthentication(); } // /endpoint and /login share the same credential-bound billing state. this.syncBilling(); if (!this.quitting && !out.silent && out.lines.length > 0) { @@ -1783,6 +1811,16 @@ export class Chat { config: () => this.configLines(), openPanel: (tab) => this.openPanel(tab), configure: (args) => this.configure(args), + provider: (args) => this.providerCommand(args), + setEndpoint: (endpoint) => { + if (endpoint === this.settings.endpoint) return; + const previous = this.settings.endpoint; + this.settings.endpoint = endpoint; + this.rebindProviderCredential(previous); + this.apiKeyVerified = false; + this.invalidateAuthentication(); + this.syncBilling(); + }, stats: () => statsRows(this.insights()).flatMap((r) => [`${r.label} ${r.value ?? ""}`, ...(r.detail ? [` ${r.detail}`] : [])]), usage: () => [...balanceRows(this.billing.state), ...usageRows(this.insights())].flatMap((r) => [`${r.label} ${r.value ?? ""}`, ...(r.detail ? [` ${r.detail}`] : [])]), themes: () => Object.entries(THEMES).map(([name, t]) => `${name.padEnd(10)} ${t.description}`), @@ -1843,7 +1881,8 @@ export class Chat { }; } - private settingSource(key: keyof StoredSettings): string { + private settingSource(key: ConfigField["key"]): string { + if (key === "provider") return this.settingSource("endpoint"); return this.changedSettings.get(key) ?? this.opts.settingSources?.[key] ?? this.opts.settingsInfo?.sources[key] ?? "default"; } @@ -1853,7 +1892,8 @@ export class Chat { return path; } - private configValue(key: keyof StoredSettings): string { + private configValue(key: ConfigField["key"]): string { + if (key === "provider") return providerForEndpoint(this.settings.endpoint) ?? "custom"; if (key === "thinking") return String(this.screen.thinkingShown); if (key === "verbose") return String(this.screen.verboseOutput); return String(this.settings[key] ?? "off"); @@ -1870,18 +1910,20 @@ export class Chat { const fail = (message: string): CommandOutput => ({ title: "/config", error: true, lines: [message] }); // A running loop captured its budgets/transport at task start. Avoid a UI // that claims a new value controls that in-flight request. - if ((this.active || this.shellBusy || this.compacting) && !["thinking", "verbose", "theme"].includes(field.key)) { + if ((this.active || this.settlingTask || this.shellBusy || this.compacting) && !["thinking", "verbose", "theme"].includes(field.key)) { return fail("Wait for the running task to finish, or close this panel and press Esc to interrupt it."); } let value: ReturnType; let path: string | null; try { value = parseConfigValue(field, raw); + if (field.key === "provider") return this.switchProvider(value as ProviderId); // Save first: a failed write must not partially apply the new setting. path = this.rememberSetting(field.key, value); } catch (err) { return fail(err instanceof Error ? err.message : String(err)); } + const previousEndpoint = this.settings.endpoint; switch (field.key) { case "thinking": if (this.screen.thinkingShown !== value) this.screen.toggleThinking(); @@ -1905,7 +1947,7 @@ export class Chat { (this.settings as unknown as Record)[field.key] = value; break; } - if (field.key === "endpoint") { this.apiKeyVerified = false; this.invalidateAuthentication(); this.syncBilling(); } + if (field.key === "endpoint") { this.rebindProviderCredential(previousEndpoint); this.apiKeyVerified = false; this.invalidateAuthentication(); this.syncBilling(); } const suffix = field.key === "channel" ? " Conversation restarted when the channel changed." : ""; return { title: "/config", @@ -1916,6 +1958,75 @@ export class Chat { }; } + private get providerLabel(): string { + const provider = providerForEndpoint(this.settings.endpoint); + return provider ? PROVIDERS[provider].label : "Custom endpoint"; + } + + private cacheProviderCredential(): void { + const provider = providerForEndpoint(this.settings.endpoint); + if (provider) this.providerCredentials[provider] = { apiKey: this.apiKey, source: this.apiKeySource }; + } + + private rebindProviderCredential(previousEndpoint: string): void { + const previous = providerForEndpoint(previousEndpoint); + const next = providerForEndpoint(this.settings.endpoint); + // Preserve custom endpoint overrides; never carry a credential across the + // Morphfactory boundary, including when changing the URL by hand. + if (previous === next) return; + if (next) this.requireKey = true; + if (previous !== "morphfactory" && next !== "morphfactory" && !(next && this.apiKey === undefined)) return; + if (previous) this.providerCredentials[previous] = { apiKey: this.apiKey, source: this.apiKeySource }; + const credential = next ? this.providerCredentials[next] : undefined; + this.apiKey = credential?.apiKey; + this.apiKeySource = credential?.source; + this.requireKey = next !== undefined; + } + + private chooseProvider(): Promise { + const providers = Object.keys(PROVIDERS) as ProviderId[]; + return this.askChoice("Choose a provider", ["Both providers serve Motif-3. Each uses its own API key.", "Change this later with /provider or /config provider."], providers.map((id, i) => `${i + 1}. ${PROVIDERS[id].label}`), true) + .then((choice) => choice === null ? null : providers[choice]!); + } + + private switchProvider(provider: ProviderId): CommandOutput { + const selected = PROVIDERS[provider]; + try { + this.rememberSetting("model", selected.model); + this.rememberSetting("endpoint", selected.endpoint); + } catch (err) { + return { title: "/provider", error: true, lines: [err instanceof Error ? err.message : String(err)] }; + } + this.cacheProviderCredential(); + this.invalidateAuthentication(); + this.settings.endpoint = selected.endpoint; + this.settings.model = selected.model; + this.apiKey = this.providerCredentials[provider]?.apiKey; + this.apiKeySource = this.providerCredentials[provider]?.source; + this.apiKeyVerified = false; + this.requireKey = true; + this.screen.setLabel(selected.model); + this.syncBilling(); + return { title: "/provider", lines: [ + `Provider: ${selected.label} · ${this.apiKey ? "API key loaded" : "no API key; use /login"}.`, + `${selected.endpoint} · ${selected.model}`, + "Saved defaults can be overridden by startup flags, environment or trusted project settings.", + ] }; + } + + private async providerCommand(args: string): Promise { + if (this.active || this.settlingTask || this.shellBusy || this.compacting) return { title: "/provider", error: true, lines: ["Wait for the running task to finish before changing provider."] }; + if (args && !Object.hasOwn(PROVIDERS, args)) return { title: "/provider", error: true, lines: ["Choose infron or morphfactory."] }; + const provider = args ? args as ProviderId : await this.chooseProvider(); + if (provider === null || this.quitting) return { title: "/provider", lines: ["Provider selection cancelled."] }; + const result = this.switchProvider(provider); + if (!result.error && this.needsLogin) { + result.lines.push(...await this.login("command")); + if (this.apiKey && providerForEndpoint(this.settings.endpoint) === provider) result.lines[0] = `Provider: ${PROVIDERS[provider].label} · API key loaded.`; + } + return result; + } + private invalidateAuthentication(): void { this.authGeneration++; if (this.verifyingLogin !== null) { @@ -1952,10 +2063,11 @@ export class Chat { private panelView(): PanelView { const panel = this.panel!; const rows: PanelView["rows"] = panel.tab === "config" - ? CONFIG_FIELDS.map((f) => ({ label: f.label, value: this.configValue(f.key), detail: `${f.detail}${f.key === "permissions" && this.alwaysAllowed.size ? ` Already allowed this session: ${[...this.alwaysAllowed].join(", ")}.` : ""} Source: ${this.settingSource(f.key)}.` })) + ? CONFIG_FIELDS.map((f) => ({ label: f.label, value: f.key === "provider" ? this.providerLabel : this.configValue(f.key), detail: `${f.detail}${f.key === "permissions" && this.alwaysAllowed.size ? ` Already allowed this session: ${[...this.alwaysAllowed].join(", ")}.` : ""} Source: ${this.settingSource(f.key)}.` })) : panel.tab === "status" ? [ { label: "Version", value: this.opts.version }, { label: "State", value: this.active ? "Running" : this.shellBusy ? "Shell running" : this.compacting ? "Compacting" : "Ready", tone: "good" }, + { label: "Provider", value: this.providerLabel }, { label: "Model", value: this.settings.model }, { label: "Endpoint", value: this.settings.endpoint }, { label: "Authentication", value: this.apiKey ? this.apiKeyVerified ? "Verified this session" : "API key loaded" : "No API key · /login" }, @@ -2055,6 +2167,7 @@ export class Chat { const s = this.settings; const src = (key: keyof LoadedSettings["sources"]): string => this.settingSource(key); const rows: [string, string, string][] = [ + ["provider", this.providerLabel, src("endpoint")], ["model", s.model, src("model")], ["endpoint", s.endpoint, src("endpoint")], ["channel", s.channel, src("channel")], @@ -2084,6 +2197,7 @@ export class Chat { const t = this.totals; const fmt = (n: number): string => n.toLocaleString("en-US"); return [ + `provider ${this.providerLabel}`, `model ${s.model}`, `endpoint ${s.endpoint}`, `channel ${s.channel} (policy ${this.opts.channelPolicy})`, diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 03e9dda..5cb272c 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -55,6 +55,9 @@ export interface CommandContext { /** Interactive dashboard; plain outputs remain available without a screen. */ openPanel?(tab: "config" | "status" | "stats" | "usage"): void; configure?(args: string): CommandOutput; + provider?(args: string): Promise; + /** Update the endpoint and its credential together in an interactive session. */ + setEndpoint?(endpoint: string): void; stats?(): string[]; usage?(): string[]; doctor(): Promise; @@ -200,9 +203,15 @@ export const COMMANDS: readonly SlashCommand[] = [ description: "probe the endpoint: auth, parsers, cache, channels", run: async (ctx) => ok("/doctor", await ctx.doctor()), }, + { + name: "provider", + description: "choose Infron or Morphfactory (BETA)", + usage: "[infron|morphfactory]", + run: (ctx, args) => ctx.provider?.(args) ?? fail("/provider", "Provider selection requires an interactive session."), + }, { name: "login", - description: "paste an Infron API key; it is checked against the endpoint and saved to ~/.motif/.env", + description: "paste the selected provider’s API key; it is checked against the endpoint and saved to ~/.motif/.env", run: async (ctx) => ok("/login", await ctx.login()), }, { @@ -227,7 +236,9 @@ export const COMMANDS: readonly SlashCommand[] = [ run: (ctx, args) => { if (args === "") return ok("/endpoint", [ctx.settings.endpoint]); if (!/^https?:\/\//.test(args)) return fail("/endpoint", `not a URL: ${args}`); - ctx.settings.endpoint = args.replace(/\/+$/, "").replace(/\/v1$/, ""); + const endpoint = args.replace(/\/+$/, "").replace(/\/v1$/, ""); + if (ctx.setEndpoint) ctx.setEndpoint(endpoint); + else ctx.settings.endpoint = endpoint; return ok("/endpoint", [ `endpoint set to ${ctx.settings.endpoint} for the next task`, ...saved(ctx, "endpoint", ctx.settings.endpoint), diff --git a/packages/cli/src/config-controls.ts b/packages/cli/src/config-controls.ts index 9b2e7f7..b9b822d 100644 --- a/packages/cli/src/config-controls.ts +++ b/packages/cli/src/config-controls.ts @@ -3,7 +3,7 @@ import { THEMES } from "@motifcode/tui"; import type { StoredSettings } from "./settings.js"; export interface ConfigField { - key: keyof StoredSettings; + key: keyof StoredSettings | "provider"; label: string; detail: string; choices?: readonly string[]; @@ -21,6 +21,7 @@ export const CONFIG_FIELDS: readonly ConfigField[] = [ { key: "maxOutputTokens", label: "Max output tokens", detail: "Positive integer per model step, or off to use the server default." }, { key: "seed", label: "Sampling seed", detail: "Integer >= 0, or off. The endpoint must support seeded sampling." }, { key: "compactAt", label: "Compact at", detail: "Context fraction from 0.1 to 1 at which automatic compaction runs." }, + { key: "provider", label: "Provider", choices: ["infron", "morphfactory"], detail: "Infron or Morphfactory (BETA). Switches endpoint, model and the provider’s own API key; /login adds a missing key." }, { key: "theme", label: "Theme", choices: Object.keys(THEMES), detail: "Terminal color palette. Applies immediately; NO_COLOR takes precedence." }, ]; diff --git a/packages/cli/src/doctor.ts b/packages/cli/src/doctor.ts index 4ae83eb..6b946e9 100644 --- a/packages/cli/src/doctor.ts +++ b/packages/cli/src/doctor.ts @@ -15,6 +15,7 @@ * it produces is not. */ +import { apiKeyEnvForEndpoint } from "@motifcode/core"; import { MAX_CONTEXT, SAMPLING_DEFAULTS } from "@motifcode/protocol"; import { detectSandbox } from "./sandbox.js"; @@ -85,6 +86,7 @@ export async function doctor(opts: DoctorOptions): Promise { const fetchImpl = opts.fetchImpl ?? globalThis.fetch; const endpoint = opts.endpoint.replace(/\/+$/, "").replace(/\/v1$/, ""); const checks: Check[] = []; + const apiKeyEnv = apiKeyEnvForEndpoint(endpoint); const headers: Record = { "content-type": "application/json" }; if (opts.apiKey) headers["authorization"] = `Bearer ${opts.apiKey}`; @@ -113,7 +115,7 @@ export async function doctor(opts: DoctorOptions): Promise { name: "api key", state: "warn", detail: "none — requests go out unauthenticated", - fix: "set MOTIF_API_KEY in the environment or in a .env file (not needed for a local server without auth)", + fix: `set ${apiKeyEnv} in the environment or in a .env file (not needed for a local server without auth)`, }, ); @@ -130,7 +132,7 @@ export async function doctor(opts: DoctorOptions): Promise { detail: `${endpoint} returned ${res.status}`, fix: res.status === 401 || res.status === 403 - ? "the endpoint refused the credentials; check MOTIF_API_KEY" + ? `the endpoint refused the credentials; check ${apiKeyEnv}` : "start the server, or point --endpoint somewhere else", }); return checks; @@ -236,7 +238,7 @@ export async function doctor(opts: DoctorOptions): Promise { name: "authentication", state: "fail", detail: `the endpoint refused the request (${res.status}${text ? `: ${text}` : ""})`, - fix: opts.apiKey ? "the key was sent and rejected; check MOTIF_API_KEY" : "set MOTIF_API_KEY", + fix: opts.apiKey ? `the key was sent and rejected; check ${apiKeyEnv}` : `set ${apiKeyEnv}`, }); return checks; } diff --git a/packages/cli/src/login.ts b/packages/cli/src/login.ts index 7e3c612..abaa387 100644 --- a/packages/cli/src/login.ts +++ b/packages/cli/src/login.ts @@ -15,10 +15,10 @@ */ import { homedir } from "node:os"; -import { ENV_API_KEY, normalizeEndpoint } from "@motifcode/core"; +import { ENV_API_KEY, ENV_MORPHFACTORY_API_KEY, PROVIDERS, normalizeEndpoint, type ProviderId } from "@motifcode/core"; /** Where a key comes from, for the prompt. */ -export const KEY_PAGE = "https://infron.ai/dashboard/apiKeys"; +export const KEY_PAGE = PROVIDERS.infron.keyPage; export type VerifyResult = { ok: true } | { ok: false; reason: string }; @@ -83,24 +83,23 @@ async function serverMessage(res: Response): Promise { * What was pasted, as a key. * * People paste the whole line from a dashboard or a `.env`: quotes, a - * `MOTIF_API_KEY=` prefix, an `export`, a trailing newline. All of that is - * the key's packaging, not the key. + * `MOTIF_API_KEY=` or `MOTIF_MORPHFACTORY_API_KEY=` prefix, an `export`, a + * trailing newline. All of that is the key's packaging, not the key. */ export function normaliseKeyInput(raw: string): string { let s = raw.trim(); - s = s.replace(new RegExp(`^(?:export\\s+)?${ENV_API_KEY}\\s*=\\s*`), ""); + s = s.replace(new RegExp(`^(?:export\\s+)?(?:${ENV_API_KEY}|${ENV_MORPHFACTORY_API_KEY})\\s*=\\s*`), ""); if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) s = s.slice(1, -1); return s.trim(); } /** The lines the login prompt shows above the input. */ -export function loginLines(envPath: string, home = homedir()): string[] { +export function loginLines(envPath: string, home = homedir(), provider: ProviderId = "infron"): string[] { const shown = envPath.startsWith(home + "/") ? `~${envPath.slice(home.length)}` : envPath; // Short lines: the box hard-wraps at its width, and a URL or a path cut // mid-word cannot be copied. return [ - `Get one at ${KEY_PAGE}`, - "Motif-3 is free there through September 2026.", + `Get one at ${PROVIDERS[provider].keyPage}`, `The key is checked with the endpoint and saved to ${shown},`, "readable only by you and never shown to the model.", ]; diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 27472f7..5728816 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -14,6 +14,10 @@ import { AgentRegistry, AgentScheduler, BUILTIN_AGENTS, concurrencyFor, parseAge import { DEFAULT_ENDPOINT, DEFAULT_MODEL, + PROVIDERS, + apiKeyEnvForEndpoint, + providerForEndpoint, + type ProviderId, HttpTransport, defaultDotenvPaths, defaultEnvPath, @@ -68,7 +72,7 @@ import { balanceRows } from "./billing-view.js"; import { describePlugins, loadPlugins, type LoadedPlugins } from "./plugins.js"; import { loadSettings, saveUserSetting } from "./settings.js"; import { commandOnPath, ranFromNpx } from "./install.js"; -import { KEY_PAGE, normaliseKeyInput, readSecret, verifyApiKey } from "./login.js"; +import { normaliseKeyInput, readSecret, verifyApiKey } from "./login.js"; import { doctor, formatChecks, worstState } from "./doctor.js"; import { ToolExecutor } from "./executor.js"; import { policyForAgent } from "./policy.js"; @@ -386,7 +390,7 @@ const HELP = `motif ${VERSION} — a coding agent built for Motif-3 (unofficial; motif open the interactive session (a terminal is required) motif "" run one task and exit; add --interactive to stay motif -p "" print only the final reply, for scripts and pipes (also --print) - motif login paste an Infron API key; it is checked and saved to ~/.motif/.env + motif login paste the selected provider's API key; it is checked and saved to ~/.motif/.env motif logout remove the saved key motif doctor check the endpoint, the credentials and what the server produces motif sessions list recorded sessions @@ -414,6 +418,7 @@ const HELP = `motif ${VERSION} — a coding agent built for Motif-3 (unofficial; motif corpus-render render a corpus JSONL to prompt text, as the harness would Flags + --provider infron | morphfactory (BETA); sets endpoint and model defaults --endpoint model server (default ${DEFAULT_ENDPOINT}; a trailing /v1 is accepted) --model model id to request (default ${DEFAULT_MODEL}) --env-file read MOTIF_* settings from this file first @@ -442,9 +447,11 @@ distil flags --include-children also export subagent scopes Connection - The first session asks for an Infron API key, checks it against the endpoint - and saves it to ~/.motif/.env; 'motif login' does the same outside a session. - MOTIF_API_KEY, MOTIF_ENDPOINT and MOTIF_MODEL are read from the environment, + The first session offers Infron or Morphfactory (BETA), checks your API key + and saves it to ~/.motif/.env; use /provider to switch inside a session. + 'motif login --provider morphfactory' signs in outside a session. + MOTIF_API_KEY (Infron), MOTIF_MORPHFACTORY_API_KEY, MOTIF_ENDPOINT and + MOTIF_MODEL are read from the environment, then from ./.env, then from ~/.motif/.env — MOTIF_* keys only, and never into the environment. The key is sent as a bearer token and is withheld from every command the agent runs. @@ -476,7 +483,7 @@ because the transcript formats are not interchangeable. /** The hosted endpoint refuses unauthenticated requests; a local server may not need a key at all. */ function keyRequired(endpoint: string): boolean { - return endpoint === DEFAULT_ENDPOINT; + return providerForEndpoint(endpoint) !== undefined; } /** @@ -486,9 +493,10 @@ function keyRequired(endpoint: string): boolean { */ async function loginAtTerminal(endpoint: string, model: string): Promise { const path = defaultEnvPath(); + const provider = providerForEndpoint(endpoint) ?? "infron"; if (process.stdin.isTTY) { process.stdout.write( - `Paste your Infron API key. Get one at ${KEY_PAGE} — Motif-3 is free there through September 2026.\n` + + `Paste your ${PROVIDERS[provider].label} API key. Get one at ${PROVIDERS[provider].keyPage}.\n` + `It is checked against ${endpoint}, then saved to ${path} (readable only by you).\n`, ); } @@ -503,7 +511,7 @@ async function loginAtTerminal(endpoint: string, model: string): Promise { projectTrusted: (content) => projectTrusted(cwd, content, args.flags["trust-project-hooks"]), warn: (m) => process.stderr.write(`${m}\n`), }); + const provider = args.flags["provider"] === undefined + ? undefined + : flagEnum(args.flags, "provider", ["infron", "morphfactory"] as const, "infron"); + const dotenvPaths = [...(envFile ? [envFile] : []), ...defaultDotenvPaths()]; const connection = resolveEndpointConfig({ flags: { + ...(provider ? { endpoint: PROVIDERS[provider].endpoint, model: PROVIDERS[provider].model } : {}), ...(typeof args.flags["endpoint"] === "string" ? { endpoint: args.flags["endpoint"] } : {}), ...(typeof args.flags["model"] === "string" ? { model: args.flags["model"] } : {}), }, - dotenvPaths: [...(envFile ? [envFile] : []), ...defaultDotenvPaths()], + dotenvPaths, defaults: { ...(stored.values.endpoint !== undefined ? { endpoint: stored.values.endpoint } : {}), ...(stored.values.model !== undefined ? { model: stored.values.model } : {}), @@ -560,6 +573,14 @@ async function main(): Promise { const compactAt = stored.values.compactAt ?? 0.75; const permissions = flagEnum(args.flags, "permissions", ["ask", "auto"] as const, stored.values.permissions ?? "ask"); const { endpoint, model, apiKey } = connection; + const providerCredentials: Partial> = {}; + for (const id of Object.keys(PROVIDERS) as ProviderId[]) { + const credentials = resolveEndpointConfig({ flags: { endpoint: PROVIDERS[id].endpoint }, dotenvPaths }); + providerCredentials[id] = { + ...(credentials.apiKey !== undefined ? { apiKey: credentials.apiKey } : {}), + ...(credentials.sources.apiKey !== undefined ? { source: credentials.sources.apiKey } : {}), + }; + } // Read once, then gone: every command the agent runs inherits this process's // environment, and a credential in it is one `env` away from a tool result. withholdSecrets(process.env); @@ -685,7 +706,7 @@ async function main(): Promise { case "logout": { const path = defaultEnvPath(); - process.stdout.write(forgetApiKey(path) ? `the key was removed from ${path}\n` : `no key in ${path}\n`); + process.stdout.write(forgetApiKey(path, providerForEndpoint(endpoint) ?? "infron") ? `the key was removed from ${path}\n` : `no key in ${path}\n`); return 0; } @@ -727,7 +748,9 @@ async function main(): Promise { } case "config": { + const selectedProvider = providerForEndpoint(endpoint); const rows: [string, string, string][] = [ + ["provider", selectedProvider ? PROVIDERS[selectedProvider].label : "custom", connection.sources.endpoint], ["model", model, connection.sources.model], ["endpoint", endpoint, connection.sources.endpoint], ["channel", stored.values.channel ?? "toolcall", stored.sources.channel ?? "default"], @@ -1005,6 +1028,8 @@ async function main(): Promise { ...(apiKey !== undefined ? { apiKey } : {}), ...(connection.sources.apiKey !== undefined ? { apiKeySource: connection.sources.apiKey } : {}), requireKey: keyRequired(endpoint), + chooseProvider: apiKey === undefined && connection.sources.endpoint === "default" && stored.values.endpoint === undefined, + providerCredentials, envPath: defaultEnvPath(), offerInstall: ranFromNpx(process.argv[1]) && !commandOnPath("motif"), skills, @@ -1057,7 +1082,7 @@ async function main(): Promise { let sessionKey = apiKey; if (sessionKey === undefined && keyRequired(endpoint)) { if (!tty) { - process.stderr.write(`no API key: run 'motif login', or put MOTIF_API_KEY in ${defaultEnvPath()}\n`); + process.stderr.write(`no API key: run 'motif login${providerForEndpoint(endpoint) === "morphfactory" ? " --provider morphfactory" : ""}', or put ${apiKeyEnvForEndpoint(endpoint)} in ${defaultEnvPath()}\n`); return 2; } const key = await loginAtTerminal(endpoint, model); diff --git a/packages/cli/test/dashboard.test.ts b/packages/cli/test/dashboard.test.ts index 5ccde70..fadba02 100644 --- a/packages/cli/test/dashboard.test.ts +++ b/packages/cli/test/dashboard.test.ts @@ -62,7 +62,7 @@ function session(extra: Partial = {}) { agents.registerAll(BUILTIN_AGENTS); const terminal = new Screen({ write: (text) => out.push(text), columns: () => 100, rows: () => 30, interactive: true, cwd }); const persist = vi.fn((key: keyof StoredSettings, value: unknown) => saveUserSetting(key, value as StoredSettings[typeof key], cwd)); - const makeTransport = vi.fn((_settings: ChatOptions["settings"]) => transport); + const makeTransport = vi.fn((_settings: ChatOptions["settings"], _apiKey?: string) => transport); const chat = new Chat({ screen: terminal, stdin: stdin as unknown as NodeJS.ReadStream, @@ -413,3 +413,113 @@ describe("account balance integration", () => { expect(s.value("Balance status")).toBe("Could not reach the balance service."); }); }); + +describe("provider selection", () => { + const morph = "https://api-cbt.morphfactory.io"; + const infron = "https://llm.onerouter.pro"; + + it("offers a first-run choice, masks the selected provider key and persists the connection", async () => { + const verifyKey = vi.fn(async () => ({ ok: true as const })); + const s = session({ chooseProvider: true, requireKey: true, verifyKey }); + expect(s.screen()).toContain("Choose a provider"); + expect(s.screen()).toContain("Morphfactory (BETA)"); + s.type("2"); + await vi.waitFor(() => expect(s.screen()).toContain("Paste your Morphfactory (BETA) API key to get started")); + expect(s.screen()).toContain("https://cbt.morphfactory.io/console?view=keys"); + s.type("mak-first\r"); + await vi.waitFor(() => expect(s.screen()).toContain("signed in")); + expect(s.screen()).not.toContain("mak-first"); + expect(s.saved()).toMatchObject({ endpoint: morph, model: "motif/motif-3" }); + expect(readFileSync(join(s.cwd, ".motif", ".env"), "utf8")).toBe("MOTIF_MORPHFACTORY_API_KEY=mak-first\n"); + s.type("go\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(s.makeTransport.mock.calls.at(-1)).toEqual([expect.objectContaining({ endpoint: morph }), "mak-first"]); + }); + + it("round-trips separate keys through /provider, /config and /endpoint without crossing credentials", async () => { + const s = session({ apiKey: "infron-key", requireKey: true, providerCredentials: { morphfactory: { apiKey: "morph-key", source: "test" } } }); + expect(s.screen()).not.toContain("Choose a provider"); + const run = async (command: string, endpoint: string, key: string, task: number) => { + s.type(command + "\r"); + await vi.waitFor(() => expect(s.screen()).toContain(command.startsWith("/endpoint") ? "endpoint set" : "Provider:")); + s.type("go\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(task)); + // ChatSettings is mutable; assert each request before the next switch. + expect(s.makeTransport.mock.calls.at(-1)).toEqual([expect.objectContaining({ endpoint }), key]); + }; + await run("/provider morphfactory", morph, "morph-key", 1); + await run("/config provider infron", infron, "infron-key", 2); + await run("/endpoint " + morph + "/v1", morph, "morph-key", 3); + await run("/provider infron", infron, "infron-key", 4); + await run("/config endpoint " + morph, morph, "morph-key", 5); + await run("/provider infron", infron, "infron-key", 6); + }); + + it("keeps endpoint and credentials together when multiple commands arrive in one input event", async () => { + const s = session({ apiKey: "infron-key", requireKey: true, providerCredentials: { morphfactory: { apiKey: "morph-key" } } }); + s.type(`/endpoint ${morph}\r/provider infron\r/provider morphfactory\rgo\r`); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(s.makeTransport.mock.calls.at(-1)).toEqual([expect.objectContaining({ endpoint: morph }), "morph-key"]); + s.type("/provider infron\rgo\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(2)); + expect(s.makeTransport.mock.calls.at(-1)).toEqual([expect.objectContaining({ endpoint: infron }), "infron-key"]); + }); + + it("does not verify a submitted key against a provider selected in the same input event", async () => { + const verifyKey = vi.fn(async () => ({ ok: true as const })); + const s = session({ apiKey: "infron-key", requireKey: true, verifyKey, providerCredentials: { morphfactory: { apiKey: "morph-key" } } }); + s.type("/login\r"); + await vi.waitFor(() => expect(s.screen()).toContain("Paste your Infron API key")); + s.type("infron-new\r/provider morphfactory\r"); + await vi.waitFor(() => expect(s.screen()).toContain("login cancelled because the session or endpoint changed")); + expect(verifyKey).not.toHaveBeenCalled(); + expect(existsSync(join(s.cwd, ".motif", ".env"))).toBe(false); + s.type("go\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + expect(s.makeTransport.mock.calls.at(-1)).toEqual([expect.objectContaining({ endpoint: morph }), "morph-key"]); + }); + + it("does not reuse an Infron key when Morphfactory login is cancelled or logged out", async () => { + const s = session({ apiKey: "infron-key", requireKey: true, verifyKey: async () => ({ ok: true }) }); + s.type("/provider morphfactory\r"); + await vi.waitFor(() => expect(s.screen()).toContain("Paste your Morphfactory (BETA) API key")); + s.type(ESC); + await vi.waitFor(() => expect(s.screen()).toContain("login cancelled")); + s.type("go\r"); + await vi.waitFor(() => expect(s.screen()).toContain("An API key is needed before the task can run")); + expect(s.makeTransport).not.toHaveBeenCalled(); + s.type("morph-new\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + s.type("/logout\r"); + await vi.waitFor(() => expect(s.screen()).toContain("the key was removed")); + s.type("/provider infron\r"); + await vi.waitFor(() => expect(s.saved().endpoint).toBe(infron)); + s.type("go\r"); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(2)); + expect(s.makeTransport.mock.calls.at(-1)?.[1]).toBe("infron-key"); + s.type("/provider morphfactory\r"); + await vi.waitFor(() => expect(s.current().secret?.title).toContain("Morphfactory")); + expect(readFileSync(join(s.cwd, ".motif", ".env"), "utf8")).not.toContain("morph-new"); + }); + + it("changes provider from the Config panel and refuses a change while a task is running", async () => { + const s = session({ apiKey: "infron-key", providerCredentials: { morphfactory: { apiKey: "morph-key" } } }); + s.type("/config\r"); + const row = s.panel().rows.findIndex((r) => r.label === "Provider"); + expect(row).toBeGreaterThanOrEqual(0); + s.type(DOWN.repeat(row) + RIGHT); + expect(s.value("Provider")).toBe("Morphfactory (BETA)"); + expect(s.saved().endpoint).toBe(morph); + s.type(ESC); + s.transport.gated = true; + s.type("go\r"); + await vi.waitFor(() => expect(s.transport.seen).toHaveLength(1)); + s.type("/provider infron\r"); + await vi.waitFor(() => expect(s.screen()).toContain("wait for the running task")); + s.type("/config provider infron\r"); + await vi.waitFor(() => expect(s.screen()).toContain("Wait for the running task")); + expect(s.saved().endpoint).toBe(morph); + s.transport.open(); + await vi.waitFor(() => expect(s.chat.tasksCompleted).toBe(1)); + }); +}); diff --git a/packages/cli/test/e2e.test.ts b/packages/cli/test/e2e.test.ts index 7eb1db1..00b2055 100644 --- a/packages/cli/test/e2e.test.ts +++ b/packages/cli/test/e2e.test.ts @@ -102,8 +102,8 @@ function runCli(argv: string[], cwd: string, env: Record = {}, o // The key is stripped from the inherited environment, and HOME points at an // empty directory, so neither a developer's own MOTIF_API_KEY nor their // ~/.motif/.env can make the unauthenticated cases pass by accident. - const { MOTIF_API_KEY: _dropped, ...inherited } = process.env; - void _dropped; + const inherited = { ...process.env }; + for (const key of ["MOTIF_API_KEY", "MOTIF_MORPHFACTORY_API_KEY", "MOTIF_ENDPOINT", "MOTIF_MODEL"]) delete inherited[key]; const home = mkdtempSync(join(tmpdir(), "motif-home-")); return new Promise((resolveRun) => { const child = spawn( @@ -580,6 +580,43 @@ describe("the credential", () => { const finish = (turn: number) => turn === 1 ? toolCall("done", { summary: "s" }) : toolCall("done", { summary: "s", confirm: true }); + it("selects a provider preset while preserving explicit endpoint and model overrides", async () => { + const preset = await runCli(["config", "--provider", "morphfactory"], dir); + expect(preset.code, preset.stderr).toBe(0); + expect(preset.stdout).toContain("Morphfactory (BETA)"); + expect(preset.stdout).toContain("https://api-cbt.morphfactory.io"); + expect(preset.stdout).toContain("motif/motif-3"); + server = new MockServer(finish); + await server.start(); + const overridden = await runCli(["t", "--provider", "morphfactory", "--endpoint", server.endpoint, "--model", "local-model", "--no-hero"], dir); + expect(overridden.code, overridden.stderr).toBe(0); + expect(server.bodies[0]!.model).toBe("local-model"); + }, 30_000); + + it("requires Morphfactory's own key and explains how to configure it", async () => { + const r = await runCli(["-p", "hello", "--provider", "morphfactory"], dir, { MOTIF_API_KEY: "sk-infron-only" }); + expect(r.code, r.stderr).toBe(2); + expect(r.stderr).toContain("motif login --provider morphfactory"); + expect(r.stderr).toContain("MOTIF_MORPHFACTORY_API_KEY"); + expect(r.stderr).not.toContain("sk-infron-only"); + }, 30_000); + + it("logs out only the selected provider", async () => { + const home = mkdtempSync(join(tmpdir(), "motif-provider-home-")); + mkdirSync(join(home, ".motif")); + const path = join(home, ".motif", ".env"); + writeFileSync(path, "MOTIF_API_KEY=sk-infron\nMOTIF_MORPHFACTORY_API_KEY=mf-key\n"); + const r = await runCli(["logout", "--provider", "morphfactory"], dir, { HOME: home }); + expect(r.code, r.stderr).toBe(0); + expect(readFileSync(path, "utf8")).toBe("MOTIF_API_KEY=sk-infron\n"); + }, 30_000); + + it("rejects unknown providers before starting a request", async () => { + const r = await runCli(["config", "--provider", "unknown"], dir); + expect(r.code).toBe(2); + expect(r.stderr).toContain("--provider must be one of infron, morphfactory"); + }, 30_000); + it("goes out as a bearer token when set in the environment", async () => { server = new MockServer(finish); await server.start(); @@ -620,22 +657,24 @@ describe("the credential", () => { // a tool result, and tool results are journalled and sent back as context. server = new MockServer((turn) => turn === 1 - ? toolCall("bash", { command: 'echo "K=${MOTIF_API_KEY:-unset}"' }) + ? toolCall("bash", { command: 'echo "K=${MOTIF_API_KEY:-unset} MF=${MOTIF_MORPHFACTORY_API_KEY:-unset}"' }) : turn === 2 ? toolCall("done", { summary: "s" }) : toolCall("done", { summary: "s", confirm: true }), ); await server.start(); - const r = await runCli(["t", "--endpoint", server.endpoint, "--no-hero"], dir, { MOTIF_API_KEY: "sk-hidden" }); + const r = await runCli(["t", "--endpoint", server.endpoint, "--no-hero"], dir, { MOTIF_API_KEY: "sk-hidden", MOTIF_MORPHFACTORY_API_KEY: "mf-hidden" }); expect(r.code).toBe(0); // The header carried it; the tool result did not. expect(server.headers[0]!["authorization"]).toBe("Bearer sk-hidden"); const toolResult = server.bodies[1]!.messages.find((m) => m.role === "tool"); - expect(toolResult?.content).toContain("K=unset"); + expect(toolResult?.content).toContain("K=unset MF=unset"); expect(JSON.stringify(server.bodies)).not.toContain("sk-hidden"); + expect(JSON.stringify(server.bodies)).not.toContain("mf-hidden"); const sessions = join(dir, ".motif", "sessions"); const journal = readFileSync(join(sessions, readdirSync(sessions)[0]!), "utf8"); expect(journal).not.toContain("sk-hidden"); + expect(journal).not.toContain("mf-hidden"); }, 30_000); it("writes the call back into history with its result", async () => { diff --git a/packages/cli/test/login.test.ts b/packages/cli/test/login.test.ts index 4ae1c87..7a15aea 100644 --- a/packages/cli/test/login.test.ts +++ b/packages/cli/test/login.test.ts @@ -4,7 +4,7 @@ import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; -import { normaliseKeyInput, readSecret, verifyApiKey } from "../src/login.js"; +import { loginLines, normaliseKeyInput, readSecret, verifyApiKey } from "../src/login.js"; function fakeFetch(status: number, body: unknown): typeof fetch { return (async () => @@ -60,6 +60,7 @@ describe("what was pasted", () => { expect(normaliseKeyInput("MOTIF_API_KEY=sk-abc123")).toBe("sk-abc123"); expect(normaliseKeyInput('export MOTIF_API_KEY="sk-abc123"')).toBe("sk-abc123"); expect(normaliseKeyInput("'sk-abc123'")).toBe("sk-abc123"); + expect(normaliseKeyInput('export MOTIF_MORPHFACTORY_API_KEY="mf-abc123"')).toBe("mf-abc123"); expect(normaliseKeyInput("")).toBe(""); }); }); @@ -78,3 +79,14 @@ describe("reading the key", () => { expect(out).toEqual([]); }); }); + + +describe("provider login guidance", () => { + it("shows the selected provider's key page", () => { + expect(loginLines("/home/user/.motif/.env", "/home/user").join("\n")).toContain("https://infron.ai/dashboard/apiKeys"); + const lines = loginLines("/home/user/.motif/.env", "/home/user", "morphfactory").join("\n"); + expect(lines).toContain("https://cbt.morphfactory.io/console?view=keys"); + expect(lines).toContain("~/.motif/.env"); + expect(lines).not.toContain("infron"); + }); +}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ae5a577..36e97ef 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -10,7 +10,7 @@ * runs — `bash`, `term`, `git apply`, project hooks — inherits that * environment, and a model that runs `env` to look around would otherwise put * the credential into a tool result, and from there into the journal. The CLI - * goes one step further and removes `MOTIF_API_KEY` from its own environment + * goes one step further and removes the provider keys from its own environment * once it has been read; see `withholdSecrets`. * * It has to be findable. A `.env` file next to where the command is typed, or @@ -32,6 +32,26 @@ export const DEFAULT_MODEL = "motif/motif-3"; export const ENV_ENDPOINT = "MOTIF_ENDPOINT"; export const ENV_MODEL = "MOTIF_MODEL"; export const ENV_API_KEY = "MOTIF_API_KEY"; +export const ENV_MORPHFACTORY_API_KEY = "MOTIF_MORPHFACTORY_API_KEY"; + +export type ProviderId = "infron" | "morphfactory"; + +export const PROVIDERS = { + infron: { + label: "Infron", + endpoint: DEFAULT_ENDPOINT, + model: DEFAULT_MODEL, + apiKeyEnv: ENV_API_KEY, + keyPage: "https://infron.ai/dashboard/apiKeys", + }, + morphfactory: { + label: "Morphfactory (BETA)", + endpoint: "https://api-cbt.morphfactory.io", + model: DEFAULT_MODEL, + apiKeyEnv: ENV_MORPHFACTORY_API_KEY, + keyPage: "https://cbt.morphfactory.io/console?view=keys", + }, +} as const satisfies Record; export interface EndpointConfig { endpoint: string; @@ -109,6 +129,22 @@ export function isInfronEndpoint(endpoint: string): boolean { return origin !== undefined && ["", "/", "/v1", "/v1/"].includes(value.slice(origin.length)); } +/** Identify a supported provider without matching lookalike hosts or custom paths. */ +export function providerForEndpoint(endpoint: string): ProviderId | undefined { + if (isInfronEndpoint(endpoint)) return "infron"; + try { + const url = new URL(endpoint.trim()); + if (url.origin === PROVIDERS.morphfactory.endpoint && !url.username && !url.password && !url.search && !url.hash + && ["/", "/v1", "/v1/"].includes(url.pathname)) return "morphfactory"; + } catch { /* A custom or invalid endpoint is not a provider preset. */ } + return undefined; +} + +/** Keep the legacy key for Infron and custom servers; never reuse it for Morphfactory. */ +export function apiKeyEnvForEndpoint(endpoint: string): string { + return providerForEndpoint(endpoint) === "morphfactory" ? ENV_MORPHFACTORY_API_KEY : ENV_API_KEY; +} + export function defaultDotenvPaths(cwd = process.cwd()): string[] { return [join(cwd, ".env"), join(homedir(), ".motif", ".env")]; } @@ -143,10 +179,11 @@ export function resolveEndpointConfig(opts: ResolveOptions = {}): EndpointConfig const endpoint = pick(opts.flags?.endpoint, ENV_ENDPOINT, opts.defaults?.endpoint ?? DEFAULT_ENDPOINT); const model = pick(opts.flags?.model, ENV_MODEL, opts.defaults?.model ?? DEFAULT_MODEL); - const apiKey = pick(undefined, ENV_API_KEY, undefined); + const normalizedEndpoint = normalizeEndpoint(endpoint.value!); + const apiKey = pick(undefined, apiKeyEnvForEndpoint(normalizedEndpoint), undefined); return { - endpoint: normalizeEndpoint(endpoint.value!), + endpoint: normalizedEndpoint, model: model.value!, ...(apiKey.value !== undefined ? { apiKey: apiKey.value } : {}), sources: { @@ -166,6 +203,7 @@ export function resolveEndpointConfig(opts: ResolveOptions = {}): EndpointConfig */ export function withholdSecrets(env: NodeJS.ProcessEnv): void { delete env[ENV_API_KEY]; + delete env[ENV_MORPHFACTORY_API_KEY]; } /* ------------------------------------------------------------------ */ @@ -214,19 +252,19 @@ export function removeDotenvKey(text: string, key: string): string { * this harness that must not be readable by another account on the machine. * An existing file keeps its other lines. Returns the path written. */ -export function saveApiKey(apiKey: string, path = defaultEnvPath()): string { +export function saveApiKey(apiKey: string, path = defaultEnvPath(), provider: ProviderId = "infron"): string { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const existing = existsSync(path) ? readFileSync(path, "utf8") : ""; - writeFileSync(path, upsertDotenv(existing, ENV_API_KEY, apiKey), { encoding: "utf8", mode: 0o600 }); + writeFileSync(path, upsertDotenv(existing, PROVIDERS[provider].apiKeyEnv, apiKey), { encoding: "utf8", mode: 0o600 }); chmodSync(path, 0o600); return path; } /** Remove the API key from the file; true when there was one to remove. */ -export function forgetApiKey(path = defaultEnvPath()): boolean { +export function forgetApiKey(path = defaultEnvPath(), provider: ProviderId = "infron"): boolean { if (!existsSync(path)) return false; const existing = readFileSync(path, "utf8"); - const next = removeDotenvKey(existing, ENV_API_KEY); + const next = removeDotenvKey(existing, PROVIDERS[provider].apiKeyEnv); if (next === existing) return false; writeFileSync(path, next, { encoding: "utf8", mode: 0o600 }); return true; diff --git a/packages/core/src/transport.ts b/packages/core/src/transport.ts index f5a4018..5b8b00e 100644 --- a/packages/core/src/transport.ts +++ b/packages/core/src/transport.ts @@ -38,7 +38,7 @@ import { type StreamDelta, type ToolCall, } from "@motifcode/protocol"; -import { isInfronEndpoint, normalizeEndpoint } from "./config.js"; +import { apiKeyEnvForEndpoint, isInfronEndpoint, normalizeEndpoint } from "./config.js"; export type { CompletionRequest, CompletionResponse }; @@ -393,9 +393,10 @@ export class HttpTransport implements Transport { if (res.status === 401 || res.status === 403) { // The one failure whose fix is never on the server. Say which side of // it the user is on: no key at all, or a key the endpoint refused. + const keyEnv = apiKeyEnvForEndpoint(this.endpoint); message += this.apiKey - ? " (the endpoint rejected the API key that was sent; check MOTIF_API_KEY)" - : " (no API key was sent; set MOTIF_API_KEY in the environment or in .env)"; + ? ` (the endpoint rejected the API key that was sent; check ${keyEnv})` + : ` (no API key was sent; set ${keyEnv} in the environment or in .env)`; } const retryAfterMs = res.status === 429 diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 0988d49..10a6390 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -13,9 +13,13 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_ENDPOINT, DEFAULT_MODEL, + ENV_MORPHFACTORY_API_KEY, + PROVIDERS, + apiKeyEnvForEndpoint, forgetApiKey, normalizeEndpoint, parseDotenv, + providerForEndpoint, removeDotenvKey, resolveEndpointConfig, saveApiKey, @@ -79,6 +83,35 @@ describe("resolution", () => { expect(c.sources.apiKey).toContain(join(d, ".env")); }); + it("selects the credential for the resolved endpoint without crossing providers", () => { + const path = join(dir({ ".env": "MOTIF_API_KEY=sk-infron\nMOTIF_MORPHFACTORY_API_KEY=sk-morph\n" }), ".env"); + const options = { env: {}, dotenvPaths: [path] }; + expect(resolveEndpointConfig(options).apiKey).toBe("sk-infron"); + const morph = resolveEndpointConfig({ ...options, flags: { endpoint: `${PROVIDERS.morphfactory.endpoint}/v1/` } }); + expect(morph.endpoint).toBe(PROVIDERS.morphfactory.endpoint); + expect(morph.model).toBe(DEFAULT_MODEL); + expect(morph.apiKey).toBe("sk-morph"); + expect(morph.sources.apiKey).toBe(`${path} (${ENV_MORPHFACTORY_API_KEY})`); + expect(providerForEndpoint("https://api-cbt.morphfactory.io:443/v1")).toBe("morphfactory"); + expect(resolveEndpointConfig({ ...options, flags: { endpoint: "https://api-cbt.morphfactory.io:443/v1" } }).apiKey).toBe("sk-morph"); + expect(resolveEndpointConfig({ ...options, flags: { endpoint: "http://localhost:8080" } }).apiKey).toBe("sk-infron"); + expect(resolveEndpointConfig({ + env: { MOTIF_API_KEY: "sk-infron" }, dotenvPaths: [], defaults: { endpoint: PROVIDERS.morphfactory.endpoint }, + }).apiKey).toBeUndefined(); + }); + + it("keeps environment and file precedence within the Morphfactory credential slot", () => { + const first = join(dir({ ".env": "MOTIF_API_KEY=sk-infron-first\n" }), ".env"); + const second = join(dir({ ".env": "MOTIF_MORPHFACTORY_API_KEY=sk-morph-file\n" }), ".env"); + const options = { dotenvPaths: [first, second], defaults: { endpoint: PROVIDERS.morphfactory.endpoint } }; + expect(resolveEndpointConfig({ ...options, env: {} }).apiKey).toBe("sk-morph-file"); + const fromEnv = resolveEndpointConfig({ ...options, env: { MOTIF_MORPHFACTORY_API_KEY: "sk-morph-env" } }); + expect(fromEnv.apiKey).toBe("sk-morph-env"); + expect(fromEnv.sources.apiKey).toBe(`environment ${ENV_MORPHFACTORY_API_KEY}`); + const overridden = resolveEndpointConfig({ ...options, env: { MOTIF_ENDPOINT: DEFAULT_ENDPOINT } }); + expect(overridden.apiKey).toBe("sk-infron-first"); + }); + it("prefers a flag over the environment over the file", () => { const d = dir({ ".env": "MOTIF_ENDPOINT=http://file\nMOTIF_MODEL=file-model\n" }); const env = { MOTIF_ENDPOINT: "http://env", MOTIF_MODEL: "env-model" }; @@ -112,12 +145,33 @@ describe("resolution", () => { }); it("withholds the key from an environment children will inherit", () => { - const env: NodeJS.ProcessEnv = { MOTIF_API_KEY: "sk-secret", PATH: "/bin" }; + const env: NodeJS.ProcessEnv = { MOTIF_API_KEY: "sk-secret", MOTIF_MORPHFACTORY_API_KEY: "sk-morph-secret", PATH: "/bin" }; withholdSecrets(env); expect(env).toEqual({ PATH: "/bin" }); }); }); +describe("provider endpoints", () => { + it("accepts the documented base URLs with optional /v1 and trailing slash", () => { + for (const provider of ["infron", "morphfactory"] as const) { + for (const suffix of ["", "/", "/v1", "/v1/"]) { + const endpoint = ` ${PROVIDERS[provider].endpoint}${suffix} `; + expect(providerForEndpoint(endpoint)).toBe(provider); + expect(apiKeyEnvForEndpoint(endpoint)).toBe(PROVIDERS[provider].apiKeyEnv); + } + } + expect(providerForEndpoint("https://API-CBT.MORPHFACTORY.IO/v1")).toBe("morphfactory"); + }); + + it("does not recognize lookalike hosts or custom paths as a provider", () => { + for (const endpoint of [ + "https://api-cbt.morphfactory.io.example.com", "https://api-cbt.morphfactory.io@evil.example", + "http://api-cbt.morphfactory.io", "https://api-cbt.morphfactory.io/proxy", + "https://api-cbt.morphfactory.io/v1?target=other", "http://localhost:8080", + ]) expect(providerForEndpoint(endpoint)).toBeUndefined(); + }); +}); + describe("writing the key", () => { it("replaces the key line in place and keeps the rest of the file", () => { const text = "# my notes\nMOTIF_ENDPOINT=https://x\nexport MOTIF_API_KEY=old\nOTHER=1\n"; @@ -151,4 +205,19 @@ describe("writing the key", () => { expect(forgetApiKey(path)).toBe(false); expect(resolveEndpointConfig({ env: {}, dotenvPaths: [path] }).apiKey).toBeUndefined(); }); + + it("saves and forgets one provider without replacing the other provider's key", () => { + const path = join(mkdtempSync(join(tmpdir(), "motif-providers-")), ".env"); + writeFileSync(path, "# keep my settings\nOTHER=1\n"); + saveApiKey("sk-infron", path); + saveApiKey("sk-morph", path, "morphfactory"); + expect(parseDotenv(readFileSync(path, "utf8"))).toEqual({ OTHER: "1", MOTIF_API_KEY: "sk-infron", MOTIF_MORPHFACTORY_API_KEY: "sk-morph" }); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(forgetApiKey(path, "morphfactory")).toBe(true); + expect(forgetApiKey(path, "morphfactory")).toBe(false); + expect(readFileSync(path, "utf8")).toBe("# keep my settings\nOTHER=1\nMOTIF_API_KEY=sk-infron\n"); + saveApiKey("sk-morph-new", path, "morphfactory"); + expect(forgetApiKey(path)).toBe(true); + expect(parseDotenv(readFileSync(path, "utf8"))).toEqual({ OTHER: "1", MOTIF_MORPHFACTORY_API_KEY: "sk-morph-new" }); + }); }); diff --git a/packages/core/test/transport.test.ts b/packages/core/test/transport.test.ts index d631a8d..bebab6c 100644 --- a/packages/core/test/transport.test.ts +++ b/packages/core/test/transport.test.ts @@ -110,6 +110,19 @@ describe("failure kinds", () => { expect(k.message).not.toContain("sk-bad"); }); + it.each([401, 403])("names the Morphfactory credential slot for a %s", async (status) => { + for (const apiKey of [undefined, "mf-rejected-key"]) { + const transport = new HttpTransport({ + endpoint: "https://api-cbt.morphfactory.io/v1/", model: "motif/motif-3", apiKey, + fetchImpl: async () => new Response("Unauthorized", { status }), + }); + const error = await transport.complete(req).catch((cause: unknown) => cause) as TransportError; + expect(error.message).toContain(apiKey ? "check MOTIF_MORPHFACTORY_API_KEY" : "set MOTIF_MORPHFACTORY_API_KEY"); + expect(error.message).not.toContain("mf-rejected-key"); + expect(error.retryable).toBe(false); + } + }); + it("treats a 2xx that is not a completion as a protocol error", async () => { // Reaching into `undefined` here used to produce an empty completion, which // reads as a model that said nothing rather than a server that answered