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 @@
-> **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의 모양을 한 터미널 코딩 에이전트입니다

- 첫 실행. 프롬프트 자리에서 Infron API 키를 물어보고, 확인한 뒤 한 번만 저장합니다.
+ 첫 실행. Provider를 선택하고 API 키를 입력하면, 확인한 뒤 한 번만 저장합니다.
|

@@ -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 @@
-> **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.

- 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.
|

@@ -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
| |