-
Notifications
You must be signed in to change notification settings - Fork 217
feat: support environment-based BYOK credentials #307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jzlikewei
wants to merge
3
commits into
main
Choose a base branch
from
feat/byok-environment-credentials
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| # BYOK environment-variable credentials | ||
|
|
||
| Store a reference in the active profile's `config.yaml` to keep an API key out of | ||
| that file. The TUI, `mcode exec`, and ACP resolve the reference from their own | ||
| process environment when they use the credential. | ||
|
|
||
| ## Configure an existing provider | ||
|
|
||
| Set `WORK_API_KEY` locally in the shell that launches `mcode`. For a POSIX shell, | ||
| read the key without displaying it or putting its value in shell history: | ||
|
|
||
| ```bash | ||
| read -s WORK_API_KEY | ||
| export WORK_API_KEY | ||
| ``` | ||
|
|
||
| In PowerShell: | ||
|
|
||
| ```powershell | ||
| $secureKey = Read-Host 'API Key' -AsSecureString | ||
| $env:WORK_API_KEY = [System.Net.NetworkCredential]::new('', $secureKey).Password | ||
| ``` | ||
|
|
||
| Edit the existing provider entry in `config.yaml`, keeping its endpoint, API | ||
| format, and model definitions: | ||
|
|
||
| ```yaml | ||
| custom_provider: | ||
| work: | ||
| options: | ||
| apiKey: '${WORK_API_KEY}' | ||
| ``` | ||
|
|
||
| The official MiniMax API key supports the same syntax: | ||
|
|
||
| ```yaml | ||
| minimax_api: | ||
| apiKey: '${MINIMAX_API_KEY}' | ||
| ``` | ||
|
|
||
| Launch `mcode` from the configured shell. Restart a running TUI, exec process, or | ||
| ACP host after changing its launch environment; changes in another terminal do | ||
| not update an existing process. A launcher or editor that starts ACP must pass | ||
| the variable to the child process too. | ||
|
|
||
| `provider add --api-key-env NAME` continues to read and save the variable's value. | ||
| To retain a reference, edit the saved credential field as shown above. | ||
|
|
||
| ## Supported syntax and errors | ||
|
|
||
| Only a complete `${NAME}` string expands. Names begin with a letter or underscore, | ||
| followed by letters, digits, or underscores. `$NAME`, `Bearer ${NAME}`, and | ||
| `prefix-${NAME}` remain literal values. A mapping such as `{env: NAME}` is rejected | ||
| as the wrong credential type. This feature does not expand Base URLs or custom | ||
| request headers. | ||
|
|
||
| Missing or whitespace-only variables fail before the model request, with an error | ||
| that identifies the provider, credential field, and variable name. Check whether | ||
| the variable exists without printing its value. Saved-provider discovery and | ||
| connection tests use the same credential resolution; cached connection status | ||
| tracks the resolved key, so changing the key invalidates a previous result. | ||
|
|
||
| ## Configuration and logs | ||
|
|
||
| Saving another setting preserves credential reference text and existing YAML | ||
| comments. When a configuration value changes, YAML aliases and merge fields are | ||
| expanded to independent values: editing one provider cannot change another | ||
| provider through a shared anchor, and clearing an inherited key stays cleared | ||
| when the file is read again. A no-op write keeps the original text. Changed | ||
| files may have normalized whitespace or indentation. | ||
|
|
||
| Numeric provider and model keys retain their identity during updates. YAML version | ||
| directives do not change the application loader's scalar interpretation during | ||
| alias expansion. Multiline plain strings retain their folded line breaks when | ||
| unrelated settings are saved. Each changed document is checked with that loader | ||
| before the configuration file is replaced. | ||
|
|
||
| Malformed configuration is rejected without replacing the existing file. POSIX | ||
| configuration permissions remain private. Runtime log fields and common | ||
| credential text are redacted, while token usage counters remain readable. This | ||
| protection does not remove credentials from older logs or files. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| import { isDeepStrictEqual } from 'node:util'; | ||
|
|
||
| import yaml from 'js-yaml'; | ||
| import { | ||
| isMap, | ||
| isNode, | ||
| isScalar, | ||
| parseDocument, | ||
| visit, | ||
| type Alias, | ||
| type Document, | ||
| type Node, | ||
| type YAMLMap, | ||
| } from 'yaml'; | ||
|
|
||
| /** | ||
| * Runtime rewrites of config.yaml must not throw away what the user wrote. | ||
| * | ||
| * The previous path parsed the file, mutated a plain object, and dumped that | ||
| * object back, which dropped every comment and re-indented the whole file. Start | ||
| * from the original document — which keeps comments and original formatting on | ||
| * every node we do not touch — and write only the paths that actually changed. | ||
| * | ||
| * The diff is bounded: nested plain objects are walked key by key so comments | ||
| * survive inside them, while anything else — a changed node kind, or a list — is | ||
| * replaced as one value. That keeps the rewrite narrow instead of inventing a | ||
| * merge rule for shapes this file does not use. | ||
| */ | ||
|
|
||
| type ConfigEdit = | ||
| | { readonly kind: 'set'; readonly path: string[]; readonly value: unknown } | ||
| | { readonly kind: 'delete'; readonly path: string[] }; | ||
|
|
||
| /** | ||
| * Parses config text into the plain-object "before" state used for the diff. | ||
| * Callers keep this separate from the object they mutate, because several write | ||
| * paths hand that mutable object to a callback that aliases into it. | ||
| */ | ||
| export function parseConfigText(text: string): Record<string, unknown> { | ||
| try { | ||
| const parsed = yaml.load(text); | ||
| return isPlainRecord(parsed) ? parsed : {}; | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Serializes `next` over `previous` while keeping comments and formatting from | ||
| * `originalText`. Falls back to a full dump when the original cannot be parsed | ||
| * into a document, so a hand-broken file still round-trips instead of vanishing. | ||
| */ | ||
| export function serializeConfigPreservingComments( | ||
| originalText: string, | ||
| previous: Record<string, unknown>, | ||
| next: Record<string, unknown>, | ||
| ): string { | ||
| const document = parseDocument(originalText, { | ||
| schema: 'core', | ||
| compat: 'yaml-1.1', | ||
| customTags: ['timestamp'], | ||
| merge: true, | ||
| }); | ||
| if (document.errors.length > 0 || (!isMap(document.contents) && originalText.trim() !== '')) { | ||
| return dumpConfig(next); | ||
| } | ||
|
|
||
| try { | ||
| const edits = collectConfigEdits(previous, next); | ||
| if (edits.length === 0) return originalText; | ||
| useConfigLoaderScalarValues(document); | ||
| materializeConfigReferences(document); | ||
| for (const edit of edits) { | ||
| const path = existingConfigPath(document, edit.path); | ||
| if (edit.kind === 'delete') { | ||
| document.deleteIn(path); | ||
| } else { | ||
| document.setIn(path, edit.value); | ||
| } | ||
| } | ||
| const serialized = document.toString({ indent: 2, lineWidth: -1 }); | ||
| // The application loader is the final authority. Reject an invalid or | ||
| // semantically different result before the atomic writer replaces the file. | ||
| if (!isDeepStrictEqual(yaml.load(serialized), yaml.load(dumpConfig(next)))) { | ||
| throw new Error('The serialized configuration does not match the intended values'); | ||
| } | ||
| return serialized; | ||
| } catch (error) { | ||
| // A self-referential anchor (`&a { self: *a }`) makes the emitter recurse | ||
| // without bound. That input cannot be written back in any form, so report | ||
| // it as a config problem instead of letting a stack overflow escape. | ||
| throw new Error( | ||
| `config.yaml could not be rewritten: ${ | ||
| error instanceof Error ? error.message : String(error) | ||
| }${error instanceof RangeError ? '. Remove the self-referential YAML anchor and retry.' : ''}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** Keep alias expansion independent of the AST parser's implicit scalar rules. */ | ||
| function useConfigLoaderScalarValues(document: Document): void { | ||
| visit(document, { | ||
| Scalar(_key, node) { | ||
| if (node.addToJSMap || typeof node.source !== 'string') return; | ||
| if (node.type !== 'PLAIN' && !node.tag) return; | ||
| // A mapping wrapper keeps values such as "---" from becoming directives. | ||
| // node.source already contains YAML's folded multiline value. Quote it | ||
| // before reparsing so its line breaks keep their meaning and indentation. | ||
| // Explicit tags still apply to quoted values. | ||
| const source = | ||
| node.type === 'PLAIN' && !node.source.includes('\n') | ||
| ? node.source | ||
| : JSON.stringify(node.source); | ||
| const tag = node.tag ? `!<${node.tag}> ` : ''; | ||
| const value = (yaml.load(`value: ${tag}${source}`) as { value: unknown }).value; | ||
| if (!isDeepStrictEqual(node.value, value)) { | ||
| node.value = value; | ||
| delete node.format; | ||
| } | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| /** JavaScript config keys are strings, while YAML keeps numeric/boolean keys typed. */ | ||
| function existingConfigKey(map: YAMLMap, key: string): unknown { | ||
| return map.items.find((pair) => isScalar(pair.key) && String(pair.key.value) === key)?.key ?? key; | ||
| } | ||
|
|
||
| function existingConfigPath(document: Document, path: readonly string[]): unknown[] { | ||
| const resolved: unknown[] = []; | ||
| let parent: unknown = document.contents; | ||
| for (const key of path) { | ||
| resolved.push(isMap(parent) ? existingConfigKey(parent, key) : key); | ||
| parent = document.getIn(resolved, true); | ||
| } | ||
| return resolved; | ||
| } | ||
|
|
||
| /** | ||
| * Provider updates can replace one options object while leaving its former | ||
| * aliases unchanged. Snapshot references before editing their sources, so YAML | ||
| * sharing cannot reintroduce coupling the application has already removed. | ||
| * Materialize merge fields too: deleting an inherited key must not expose the | ||
| * value again through `<<`. Explicit nodes retain their comments and styles. | ||
| */ | ||
| function materializeConfigReferences(document: Document): void { | ||
| const aliases = new Map<Alias, Node>(); | ||
| visit(document, { | ||
| Alias(_key, alias) { | ||
| const value: unknown = alias.toJS(document); | ||
| const node = createConfigNode(document, value); | ||
| node.comment = alias.comment; | ||
| node.commentBefore = alias.commentBefore; | ||
| node.spaceBefore = alias.spaceBefore; | ||
| aliases.set(alias, node); | ||
| }, | ||
| }); | ||
| visit(document, { Alias: (_key, alias) => aliases.get(alias) }); | ||
| visit(document, { | ||
| Map(_key, map) { | ||
| const merges = map.items.filter((pair) => isScalar(pair.key) && pair.key.addToJSMap); | ||
| if (merges.length === 0) return; | ||
| const values = map.toJS(document) as Record<string, unknown>; | ||
| const comments = merges.flatMap((pair) => | ||
| [pair.key, pair.value].flatMap((node) => | ||
| isNode(node) ? [node.commentBefore, node.comment] : [], | ||
| ), | ||
| ); | ||
| map.commentBefore = [map.commentBefore, ...comments].filter(Boolean).join('\n') || undefined; | ||
| map.items = map.items.filter((pair) => !merges.includes(pair)); | ||
| for (const [key, value] of Object.entries(values)) { | ||
| if (!map.has(existingConfigKey(map, key))) { | ||
| map.set(key, createConfigNode(document, value)); | ||
| } | ||
| } | ||
| }, | ||
| }); | ||
| // All merge pairs are gone. Disable merge emission, and quote strings that | ||
| // either scalar schema could interpret (including the loader's 0b integers). | ||
| document.setSchema(document.directives?.yaml.version ?? '1.2', { | ||
| schema: 'core', | ||
| compat: 'yaml-1.1', | ||
| customTags: ['timestamp'], | ||
| merge: false, | ||
| }); | ||
| } | ||
|
|
||
| function createConfigNode(document: Document, value: unknown): Node { | ||
| const node = document.createNode(value, { aliasDuplicateObjects: false }); | ||
| // A resolved object's literal "<<" property has already lost its YAML quote | ||
| // metadata. Quote it again so materialization cannot turn it into a merge. | ||
| visit(node, { | ||
| Pair(_key, pair) { | ||
| if (isScalar(pair.key) && pair.key.value === '<<') pair.key.type = 'QUOTE_DOUBLE'; | ||
| }, | ||
| }); | ||
| return node; | ||
| } | ||
|
|
||
| function collectConfigEdits(previous: unknown, next: unknown, path: string[] = []): ConfigEdit[] { | ||
| if (!isPlainRecord(previous) || !isPlainRecord(next)) { | ||
| return valuesEqual(previous, next) ? [] : [{ kind: 'set', path, value: next }]; | ||
| } | ||
| const edits: ConfigEdit[] = []; | ||
| for (const key of new Set([...Object.keys(previous), ...Object.keys(next)])) { | ||
| const childPath = [...path, key]; | ||
| // A caller that assigns undefined is removing the value; writing it back | ||
| // would emit `key: null`, which reads as a configured-but-empty field. | ||
| if (!(key in next) || next[key] === undefined) { | ||
| edits.push({ kind: 'delete', path: childPath }); | ||
| } else if (!(key in previous)) { | ||
| edits.push({ kind: 'set', path: childPath, value: next[key] }); | ||
| } else { | ||
| edits.push(...collectConfigEdits(previous[key], next[key], childPath)); | ||
| } | ||
| } | ||
| return edits; | ||
| } | ||
|
|
||
| function valuesEqual(left: unknown, right: unknown): boolean { | ||
| if (left === right) return true; | ||
| if (left === null || right === null) return false; | ||
| if (typeof left !== 'object' || typeof right !== 'object') return false; | ||
| try { | ||
| return JSON.stringify(left) === JSON.stringify(right); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function dumpConfig(next: Record<string, unknown>): string { | ||
| return yaml.dump(next, { indent: 2, lineWidth: -1, noRefs: true }); | ||
| } | ||
|
|
||
| function isPlainRecord(value: unknown): value is Record<string, unknown> { | ||
| return Boolean(value) && typeof value === 'object' && !Array.isArray(value); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Materialize aliases using the configuration loader's scalar semantics
The before/after objects come from
js-yaml, butalias.toJS(document)uses theyamlpackage and honors the source document's%YAML 1.1directive. These parsers disagree on scalar values: the configuration loader retainsonas a string, while this alias conversion resolves it to booleantrue.With two providers sharing an
optionsanchor containingheaders: { X-Feature: on }, I reproduced this throughupdateLocalModelSelection: changing only the default model rewrites the aliased provider's header toX-Feature: true. The original provider remains unchanged, and the base writer preserves the string"on"for both providers. This silently changes a request header during an unrelated settings save.Please preserve the configuration loader's scalar semantics when parsing and materializing aliases so that unrelated values survive the write unchanged.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in d4db8c6, with an additional multiline-string fix in c4f6076. Scalar values are reconciled with the application's
js-yamlinterpretation before aliases and merges are expanded. Emission preserves strings that could otherwise be interpreted as booleans or numbers, and the final loader check rejects any semantic mismatch before writing.The regression through
updateLocalModelSelectionnow preservesX-Feature: onas a string for both providers under%YAML 1.1, including the quoted numeric-looking header"0b10". Additional cases cover aliases, merges, dates, explicit tags, and multiline plain strings with folded line breaks.The complete local
pnpm verifyrun on c4f6076 passed all 14 applicable gates on macOS arm64 / Node.js 22.22.2. These are local verification results; they do not imply that all GitHub CI checks have passed.