Skip to content

Commit 2ea3753

Browse files
committed
fix duplicate config issue
1 parent ab27f2e commit 2ea3753

4 files changed

Lines changed: 150 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<h1 align="center">copilot-bridge</h1>
22

33
<p align="center">
4-
<a href="https://www.npmjs.com/package/betahi-copilot-bridge"><img src="https://img.shields.io/npm/v/betahi-copilot-bridge.svg?v=0.20.9" alt="npm version"></a>
4+
<a href="https://www.npmjs.com/package/betahi-copilot-bridge"><img src="https://img.shields.io/npm/v/betahi-copilot-bridge.svg?v=0.20.10" alt="npm version"></a>
55
<a href="https://github.com/betahi/copilot-bridge/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/betahi-copilot-bridge.svg" alt="license"></a>
66
</p>
77

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "betahi-copilot-bridge",
3-
"version": "0.20.9",
3+
"version": "0.20.10",
44
"description": "Model-layer bridge for routing Codex CLI, Claude Code, and similar clients to GitHub Copilot.",
55
"keywords": [
66
"github-copilot",

src/lib/codex-config.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ const END_MARK =
1212
// upgrading users do not end up with duplicate managed blocks.
1313
const LEGACY_BEGIN_MARK = "# >>> copilot-bridge managed (do not edit) >>>"
1414
const LEGACY_END_MARK = "# <<< copilot-bridge managed (do not edit) <<<"
15+
const MANAGED_MARKERS = new Set([
16+
BEGIN_MARK,
17+
END_MARK,
18+
LEGACY_BEGIN_MARK,
19+
LEGACY_END_MARK,
20+
])
1521

1622
// Top-level keys that the user (or codex itself) is the owner of.
1723
// We never put these in our managed block to avoid TOML duplicate-key errors.
@@ -129,6 +135,37 @@ function stripManagedBlock(content: string): string {
129135
return next
130136
}
131137

138+
function removeStrayManagedMarkerLines(content: string): string {
139+
return content
140+
.split("\n")
141+
.filter((line) => !MANAGED_MARKERS.has(line.trim()))
142+
.join("\n")
143+
}
144+
145+
function removeManagedProviderTables(
146+
content: string,
147+
providerId: string,
148+
): string {
149+
const lines = content.split("\n")
150+
const out: Array<string> = []
151+
const tableHeader = `[model_providers.${providerId}]`
152+
153+
for (let i = 0; i < lines.length;) {
154+
if (lines[i].trim() === tableHeader) {
155+
i += 1
156+
while (i < lines.length && !/^\s*\[/.test(lines[i])) {
157+
i += 1
158+
}
159+
continue
160+
}
161+
162+
out.push(lines[i])
163+
i += 1
164+
}
165+
166+
return out.join("\n").replace(/\n{3,}/g, "\n\n")
167+
}
168+
132169
// Lines belonging to the first (top-level) TOML section: from the start of
133170
// the file up to the first line that begins with `[`. This is where
134171
// codex's own `model = ...` and `model_reasoning_effort = ...` live.
@@ -203,16 +240,23 @@ function normalizeModelContextWindow(
203240
return normalized > 0 ? normalized : undefined
204241
}
205242

206-
function removeTopLevelContextWindowWhenManaged(
243+
function removeManagedTopLevelKeys(
207244
content: string,
208245
input: ApplyCodexConfigInput,
209246
): string {
210-
if (normalizeModelContextWindow(input.modelContextWindow) === undefined) {
211-
return content
247+
const { top, rest } = splitTopSection(content)
248+
let nextTop = top
249+
250+
if (input.settings.setAsDefault) {
251+
nextTop = removeTopKey(nextTop, "model_provider")
212252
}
213253

214-
const { top, rest } = splitTopSection(content)
215-
const nextTop = removeTopKey(top, "model_context_window")
254+
if (normalizeModelContextWindow(input.modelContextWindow) !== undefined) {
255+
nextTop = removeTopKey(nextTop, "model_context_window")
256+
}
257+
258+
nextTop = removeTopKey(nextTop, "model_supports_reasoning_summaries")
259+
216260
if (nextTop === top) return content
217261
if (rest.length === 0) {
218262
return nextTop.endsWith("\n") ? nextTop : `${nextTop}\n`
@@ -272,8 +316,10 @@ export async function applyCodexConfig(
272316
}
273317

274318
let stripped = stripManagedBlock(existing)
319+
stripped = removeStrayManagedMarkerLines(stripped)
320+
stripped = removeManagedProviderTables(stripped, input.settings.providerId)
275321
stripped = applyUserScalars(stripped, input)
276-
stripped = removeTopLevelContextWindowWhenManaged(stripped, input)
322+
stripped = removeManagedTopLevelKeys(stripped, input)
277323
const block = buildManagedBlock(input)
278324
const { top, rest } = splitTopSection(stripped)
279325
const parts = [top, block, rest]

tests/codex-config.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,4 +366,100 @@ trust_level = "trusted"
366366
"[projects.",
367367
)
368368
})
369+
370+
test("recovers from orphan managed block and stale provider table", async () => {
371+
const p = await makeConfigPath()
372+
await writeFile(
373+
p,
374+
`model = "gpt-5.5"
375+
model_reasoning_effort = "xhigh"
376+
model_auto_compact_token_limit = 200000
377+
378+
# >>> copilot-bridge managed block — auto-generated, do not edit between markers >>>
379+
model_provider = "bridge"
380+
381+
# >>> copilot-bridge managed block — auto-generated, do not edit between markers >>>
382+
model_provider = "bridge"
383+
model_context_window = 1050000
384+
model_supports_reasoning_summaries = true
385+
386+
[model_providers.bridge]
387+
name = "Copilot Bridge"
388+
base_url = "http://old/v1"
389+
wire_api = "responses"
390+
prefer_websockets = false
391+
requires_openai_auth = false
392+
# <<< copilot-bridge managed block — edits outside this block are preserved <<<
393+
394+
[model_providers.bridge]
395+
name = "Copilot Bridge"
396+
base_url = "http://old/v1"
397+
wire_api = "responses"
398+
prefer_websockets = false
399+
requires_openai_auth = false
400+
401+
[tui.model_availability_nux]
402+
"gpt-5.5" = 4
403+
404+
[projects."/Users/z/ai_run"]
405+
trust_level = "trusted"
406+
`,
407+
)
408+
await applyCodexConfig({
409+
configPath: p,
410+
baseUrl: "http://127.0.0.1:4242/v1",
411+
settings: baseSettings,
412+
modelContextWindow: 1050000,
413+
})
414+
const content = await readFile(p, "utf8")
415+
416+
expect(content.match(/^model_provider = /gm)).toHaveLength(1)
417+
expect(content.match(/^model_context_window = /gm)).toHaveLength(1)
418+
expect(content.match(/^model_supports_reasoning_summaries = /gm)).toHaveLength(1)
419+
expect(content.match(/^\[model_providers\.bridge\]$/gm)).toHaveLength(1)
420+
expect(content.match(/copilot-bridge managed block auto-generated/g)).toHaveLength(1)
421+
expect(content.match(/copilot-bridge managed block edits outside/g)).toHaveLength(1)
422+
expect(content).toContain("model_auto_compact_token_limit = 200000")
423+
expect(content).toContain('[projects."/Users/z/ai_run"]')
424+
expect(content).toContain('[tui.model_availability_nux]')
425+
expect(content).toContain('base_url = "http://127.0.0.1:4242/v1"')
426+
})
427+
428+
test("preserves non-bridge provider tables while replacing bridge provider", async () => {
429+
const p = await makeConfigPath()
430+
await writeFile(
431+
p,
432+
`model = "gpt-5.5"
433+
434+
[model_providers.openai]
435+
name = "OpenAI"
436+
base_url = "https://api.openai.com/v1"
437+
wire_api = "responses"
438+
439+
[model_providers.bridge]
440+
name = "Old Bridge"
441+
base_url = "http://old/v1"
442+
wire_api = "responses"
443+
444+
[profiles.work]
445+
model_provider = "openai"
446+
`,
447+
)
448+
await applyCodexConfig({
449+
configPath: p,
450+
baseUrl: "http://127.0.0.1:4242/v1",
451+
settings: baseSettings,
452+
modelContextWindow: 1050000,
453+
})
454+
const content = await readFile(p, "utf8")
455+
456+
expect(content).toContain("[model_providers.openai]")
457+
expect(content).toContain('base_url = "https://api.openai.com/v1"')
458+
expect(content).toContain("[profiles.work]")
459+
expect(content).toContain('model_provider = "openai"')
460+
expect(content.match(/^\[model_providers\.bridge\]$/gm)).toHaveLength(1)
461+
expect(content).toContain('name = "Copilot Bridge"')
462+
expect(content).toContain('base_url = "http://127.0.0.1:4242/v1"')
463+
expect(content).not.toContain('name = "Old Bridge"')
464+
})
369465
})

0 commit comments

Comments
 (0)