Compare commits

...

39 Commits

Author SHA1 Message Date
CJACK.
dbf2bfb64f Merge pull request #466 from CJackHwang/dev
Merge pull request #465 from CJackHwang/codex/review-and-update-project-documentation

docs: clarify Vercel chat-stream supports root `/chat/completions` alias
2026-05-09 19:46:45 +08:00
CJACK.
9e9a7f1bec Merge pull request #467 from CJackHwang/codex/fix-dsml-delimiter-consistency-in-examples
fix(toolcall): unify DSML delimiter in correct examples
2026-05-09 19:37:40 +08:00
CJACK.
96691aa37a fix(toolcall): unify DSML delimiter in correct examples 2026-05-09 19:29:36 +08:00
CJACK.
a3ce8008af Merge pull request #465 from CJackHwang/codex/review-and-update-project-documentation
docs: clarify Vercel chat-stream supports root `/chat/completions` alias
2026-05-09 19:00:27 +08:00
CJACK.
23a79df687 docs: sync Vercel chat-stream path alias in docs 2026-05-09 18:59:08 +08:00
CJACK.
e251a7ee29 Merge pull request #463 from CJackHwang/codex/align-vercel-js-path-with-go-implementation-tqm8ci
Align Vercel JS CORS Vary-Origin behavior with Go
2026-05-09 18:27:39 +08:00
CJACK.
30cca7cda0 Merge pull request #462 from CJackHwang/codex/align-vercel-js-path-with-go-implementation
fix(vercel): align JS stream path guard with Go /chat/completions alias
2026-05-09 18:26:35 +08:00
CJACK.
ab163edee7 Merge pull request #461 from CJackHwang/codex/refactor-hasasciiprefixfoldat-and-hasdsmlprefix
抽取通用 ASCII 部分前缀匹配以合并重复的 DSML 前缀逻辑
2026-05-09 18:20:31 +08:00
CJACK.
1201c3773f Align Vercel JS CORS Vary-Origin behavior with Go 2026-05-09 18:17:16 +08:00
CJACK.
595ddf52af fix(vercel): align js stream path guard with go chat alias 2026-05-09 18:16:47 +08:00
CJACK.
0adffccd46 refactor tool markup prefix folding helpers 2026-05-09 18:09:12 +08:00
CJACK.
0670d5acb4 Update VERSION 2026-05-09 17:54:26 +08:00
CJACK.
239c4faa97 Merge pull request #460 from waiwaic/main
fix(toolcall): eliminate strings.ToLower panics from Unicode case folding
2026-05-09 16:56:46 +08:00
waiwai
f33789399e fix(toolcall): correct DSML closing tag slash position
The closing tag format was <|/DSML|tag> but must be </|DSML|tag>.
The scanner's closing-tag detection checks text[1] == '/', so the
slash must come immediately after '<', before the first full-width
pipe (U+FF5C). Tags like <|/DSML|tool_calls> would not set
closing=true and would not match any tool markup name.

Files fixed:
- internal/toolcall/tool_prompt.go: all closing tags
- internal/promptcompat/prompt_build_test.go: 1 test expectation
2026-05-09 16:42:22 +08:00
waiwai
1e00e482a6 fix(toolcall): eliminate strings.ToLower panics from Unicode case folding
Replace all strings.ToLower usage with ASCII case-insensitive matching
(hasASCIIPrefixFoldAt, indexASCIIFold, hasDSMLPrefix) to prevent slice
bounds errors when Unicode characters change byte length after case
folding (e.g., Turkish İ U+0130 → i + combining dot: 2 bytes → 3 bytes).

Root cause: code created a strings.ToLower(text) copy, found byte
positions in that copy, then used those positions to slice the
original text — byte offsets that were valid in the lowercased copy
became out-of-bounds in the original when case folding changed byte
lengths.

Files changed:
- toolcalls_scan.go: remove 5 lower usages, add hasDSMLPrefix
- toolcalls_parse_markup.go: remove 3 lower usages, add indexASCIIFold
- toolcalls_markup.go: SanitizeLooseCDATA lower removal
- toolcalls_parse.go: updateCDATAStateForStrip lower removal
- tool_prompt.go: align DSML pipe characters with tool call spec
- tool_prompt_test.go: fix pre-existing test character mismatch
2026-05-09 15:05:51 +08:00
CJACK.
7ab5a0e66d Merge pull request #458 from CJackHwang/dev
Avoid lowercasing ignored XML tails in toolcall
2026-05-08 17:13:00 +08:00
CJACK.
410efbd70b Merge pull request #457 from NgoQuocViet2001/ai/skipxml-lower-hotpath
fix(toolcall): avoid lowercasing ignored XML tails
2026-05-08 17:05:28 +08:00
NgoQuocViet2001
7179b995bb fix(toolcall): avoid lowercasing ignored XML tails 2026-05-08 14:15:32 +07:00
CJACK.
fef3798e5e Merge pull request #453 from CJackHwang/dev
Fix character length calculation issue
2026-05-08 13:40:47 +08:00
CJACK.
00fe18b505 Update VERSION 2026-05-08 13:36:17 +08:00
CJACK.
9b746e32d8 Merge pull request #452 from waiwaic/fix/turkish-i-boundary-panic
fix(toolcall): use len(lower) not len(text) after ToLower to prevent out-of-bounds panic
2026-05-08 13:34:28 +08:00
waiwai
ace440481a refactor(toolcall): remove lower param from skipXMLIgnoredSection
The lower parameter was a footgun: callers had to keep it in sync with the
loop bound over text. Instead, skipXMLIgnoredSection now accepts only text
and constructs strings.ToLower(tail) internally for its prefix checks.

This eliminates the entire class of len(text) vs len(lower) boundary bugs
along with the min() workaround.

Also changes:
- findToolCDATAEnd: drop lower param, use text directly for closeMarker
  search (]]> is ASCII, ToLower is a no-op for it)
- cdataEndLooksStructural: drop lower param, use raw text byte comparison
- All external callers: loop bound reverts to plain len(text)

The inner tag-matching functions (findXMLStartTagOutsideCDATA,
findMatchingXMLEndTagOutsideCDATA) retain their own local lower for
HasPrefix comparisons against the target tag name, keeping concerns
properly separated.

Fixes #435.
2026-05-08 13:29:21 +08:00
CJACK.
66e0fa568f Merge pull request #449 from CJackHwang/dev
Update
2026-05-08 01:24:16 +08:00
CJACK.
fa489248bc Merge pull request #450 from CJackHwang/codex/add-json-tag-for-ollama-model-id
Add Ollama-compatible API endpoints and model capability support
2026-05-08 01:21:41 +08:00
CJACK.
657b9379ed test(docs): assert ollama show id field and document ollama endpoints 2026-05-08 01:11:35 +08:00
CJACK.
9062330104 Merge pull request #446 from dinhnn/main
feat: add Ollama API endpoints /api/version, /api/tags, /api/show for Copilot integration
2026-05-08 00:54:16 +08:00
Dinh Nguyen
d0d61a5d77 Update ollama api test 2026-05-07 14:23:12 +07:00
dinhnn
ffef451f7a Fixbug test typing 2026-05-07 13:48:03 +07:00
Dinh Nguyen
a68a79e087 Add ollama api for copilot support 2026-05-07 09:41:46 +07:00
CJACK.
c8db66615c Update VERSION 2026-05-06 13:04:16 +08:00
CJACK.
79ae9c8970 Merge pull request #436 from waiwaic/main
fix: auto-detect Vercel for chat history path
2026-05-06 13:03:30 +08:00
waiwai
2378f0fbe7 fix: auto-detect Vercel for chat history path
On Vercel, /var/task is read-only at runtime. ChatHistoryPath() now
auto-detects Vercel via IsVercel() and defaults to /tmp/chat_history.json
when no explicit DS2API_CHAT_HISTORY_PATH is set. Manual env var still
works as explicit override.
2026-05-06 11:10:14 +08:00
CJACK.
aa29084038 Merge pull request #434 from CJackHwang/dev
Merge pull request #433 from CJackHwang/codex/flash-searchpro-search

Remove heuristic model name resolution and require explicit aliases or canonical IDs
2026-05-06 00:38:20 +08:00
CJACK.
21c1527c79 Merge pull request #433 from CJackHwang/codex/flash-searchpro-search
Remove heuristic model name resolution and require explicit aliases or canonical IDs
2026-05-06 00:03:44 +08:00
CJACK.
7ec0d99702 Merge pull request #431 from CJackHwang/main
Fix OpenAI stream heartbeat and avoid empty choices
2026-05-06 00:02:25 +08:00
CJACK.
7e639667f8 refactor: remove heuristic model resolution and enforce allowlist 2026-05-06 00:00:27 +08:00
CJACK.
066c48c107 Bump version from 4.4.2 to 4.4.3 2026-05-05 22:29:36 +08:00
CJACK.
d69b0658ea Merge pull request #430 from NgoQuocViet2001/ai/openai-stream-empty-choices
fix(openai): avoid empty choices stream heartbeat
2026-05-05 22:24:21 +08:00
NgoQuocViet2001
4315b424bf fix(openai): keep stream heartbeat choice-free 2026-05-05 21:13:38 +07:00
26 changed files with 615 additions and 239 deletions

View File

@@ -18,6 +18,7 @@ Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Depl
- [OpenAI-Compatible API](#openai-compatible-api) - [OpenAI-Compatible API](#openai-compatible-api)
- [Claude-Compatible API](#claude-compatible-api) - [Claude-Compatible API](#claude-compatible-api)
- [Gemini-Compatible API](#gemini-compatible-api) - [Gemini-Compatible API](#gemini-compatible-api)
- [Ollama API](#ollama-api)
- [Admin API](#admin-api) - [Admin API](#admin-api)
- [Error Payloads](#error-payloads) - [Error Payloads](#error-payloads)
- [cURL Examples](#curl-examples) - [cURL Examples](#curl-examples)
@@ -123,6 +124,9 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key=
| POST | `/v1beta/models/{model}:streamGenerateContent` | Business | Gemini stream | | POST | `/v1beta/models/{model}:streamGenerateContent` | Business | Gemini stream |
| POST | `/v1/models/{model}:generateContent` | Business | Gemini non-stream compat path | | POST | `/v1/models/{model}:generateContent` | Business | Gemini non-stream compat path |
| POST | `/v1/models/{model}:streamGenerateContent` | Business | Gemini stream compat path | | POST | `/v1/models/{model}:streamGenerateContent` | Business | Gemini stream compat path |
| GET | `/api/version` | None | Ollama version endpoint |
| GET | `/api/tags` | None | Ollama model list |
| POST | `/api/show` | None | Ollama model capability query (returns `id` + `capabilities`) |
| POST | `/admin/login` | None | Admin login | | POST | `/admin/login` | None | Admin login |
| GET | `/admin/verify` | JWT | Verify admin JWT | | GET | `/admin/verify` | JWT | Verify admin JWT |
| GET | `/admin/vercel/config` | Admin | Read preconfigured Vercel creds | | GET | `/admin/vercel/config` | Admin | Read preconfigured Vercel creds |
@@ -239,6 +243,8 @@ Retired historical families such as `claude-1.*`, `claude-2.*`, `claude-instant-
### `POST /v1/chat/completions` ### `POST /v1/chat/completions`
> Path note: besides the canonical `/v1/chat/completions`, DS2API also accepts the root shortcut `/chat/completions`. On Vercel Runtime, `stream=true` on either path is handled by the Node streaming bridge, while non-stream stays on the Go primary path.
**Headers**: **Headers**:
```http ```http
@@ -617,6 +623,20 @@ Returns SSE (`text/event-stream`), each chunk as `data: <json>`:
--- ---
## Ollama API
- `POST /api/show` request body: `{"model":"<model-id>"}`.
- Response uses lowercase `id` (not `ID`) and includes `capabilities` for Ollama-style clients and strict schemas.
Example response:
```json
{
"id": "deepseek-v4-flash",
"capabilities": ["tools", "thinking"]
}
```
## Admin API ## Admin API
### `POST /admin/login` ### `POST /admin/login`

20
API.md
View File

@@ -18,6 +18,7 @@
- [OpenAI 兼容接口](#openai-兼容接口) - [OpenAI 兼容接口](#openai-兼容接口)
- [Claude 兼容接口](#claude-兼容接口) - [Claude 兼容接口](#claude-兼容接口)
- [Gemini 兼容接口](#gemini-兼容接口) - [Gemini 兼容接口](#gemini-兼容接口)
- [Ollama 兼容接口](#ollama-兼容接口)
- [Admin 接口](#admin-接口) - [Admin 接口](#admin-接口)
- [错误响应格式](#错误响应格式) - [错误响应格式](#错误响应格式)
- [cURL 示例](#curl-示例) - [cURL 示例](#curl-示例)
@@ -125,6 +126,9 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=`
| POST | `/v1beta/models/{model}:streamGenerateContent` | 业务 | Gemini 流式 | | POST | `/v1beta/models/{model}:streamGenerateContent` | 业务 | Gemini 流式 |
| POST | `/v1/models/{model}:generateContent` | 业务 | Gemini 非流式兼容路径 | | POST | `/v1/models/{model}:generateContent` | 业务 | Gemini 非流式兼容路径 |
| POST | `/v1/models/{model}:streamGenerateContent` | 业务 | Gemini 流式兼容路径 | | POST | `/v1/models/{model}:streamGenerateContent` | 业务 | Gemini 流式兼容路径 |
| GET | `/api/version` | 无 | Ollama 版本接口 |
| GET | `/api/tags` | 无 | Ollama 模型列表 |
| POST | `/api/show` | 无 | Ollama 单模型能力查询(返回 `id``capabilities` |
| POST | `/admin/login` | 无 | 管理登录 | | POST | `/admin/login` | 无 | 管理登录 |
| GET | `/admin/verify` | JWT | 校验管理 JWT | | GET | `/admin/verify` | JWT | 校验管理 JWT |
| GET | `/admin/vercel/config` | Admin | 读取 Vercel 预配置 | | GET | `/admin/vercel/config` | Admin | 读取 Vercel 预配置 |
@@ -245,6 +249,8 @@ OpenAI `/v1/*` 仍是规范路径。对于只配置 DS2API 根地址的客户端
### `POST /v1/chat/completions` ### `POST /v1/chat/completions`
> 路径说明:除规范路径 `/v1/chat/completions` 外,也支持根路径快捷别名 `/chat/completions`;在 Vercel Runtime 上,这两个路径的 `stream=true` 请求都会进入 Node 流式桥接逻辑,非流式仍走 Go 主链路。
**请求头** **请求头**
```http ```http
@@ -628,6 +634,20 @@ data: {"type":"message_stop"}
--- ---
## Ollama 兼容接口
- `POST /api/show` 请求体:`{"model":"<model-id>"}`
- 响应字段使用小写 `id`(不是 `ID`),并返回 `capabilities` 数组,便于与 Ollama 风格客户端/严格 schema 对齐。
示例响应:
```json
{
"id": "deepseek-v4-flash",
"capabilities": ["tools", "thinking"]
}
```
## Admin 接口 ## Admin 接口
### `POST /admin/login` ### `POST /admin/login`

View File

@@ -295,7 +295,7 @@ cp config.example.json config.json
base64 < config.json | tr -d '\n' base64 < config.json | tr -d '\n'
``` ```
> **流式说明**`/v1/chat/completions` 在 Vercel 上默认走 `api/chat-stream.js`Node Runtime以保证实时 SSE。鉴权、账号选择、会话/PoW 准备仍由 Go 内部 prepare 接口完成;流式响应(含 `tools`)在 Node 侧执行与 Go 对齐的输出组装与防泄漏处理。虽然这里只有 OpenAI chat 流式走 Node但 CORS 放行策略仍与 Go 主路由保持一致,统一覆盖第三方客户端预检场景。 > **流式说明**OpenAI Chat 流式在 Vercel 上会由 `api/chat-stream.js`Node Runtime承接,支持规范路径 `/v1/chat/completions` 与根路径快捷别名 `/chat/completions`。鉴权、账号选择、会话/PoW 准备仍由 Go 内部 prepare 接口完成;流式响应(含 `tools`)在 Node 侧执行与 Go 对齐的输出组装与防泄漏处理。虽然这里只有 OpenAI chat 流式走 Node但 CORS 放行策略仍与 Go 主路由保持一致,统一覆盖第三方客户端预检场景。
详细部署说明请参阅 [部署指南](docs/DEPLOY.md)。 详细部署说明请参阅 [部署指南](docs/DEPLOY.md)。

View File

@@ -283,7 +283,7 @@ Recommended: convert `config.json` to Base64 locally, then paste into `DS2API_CO
base64 < config.json | tr -d '\n' base64 < config.json | tr -d '\n'
``` ```
> **Streaming note**: `/v1/chat/completions` on Vercel is routed to `api/chat-stream.js` (Node Runtime) for real-time SSE. Auth, account selection, and session/PoW preparation are still handled by the Go internal prepare endpoint; streaming output (including `tools`) is assembled on Node with Go-aligned anti-leak handling. This is the only interface family currently routed through Node, and its CORS allow behavior is kept aligned with the Go router so third-party preflight handling stays unified. > **Streaming note**: OpenAI Chat streaming on Vercel is routed to `api/chat-stream.js` (Node Runtime), with both the canonical `/v1/chat/completions` path and the root shortcut `/chat/completions` supported. Auth, account selection, and session/PoW preparation are still handled by the Go internal prepare endpoint; streaming output (including `tools`) is assembled on Node with Go-aligned anti-leak handling. This is the only interface family currently routed through Node, and its CORS allow behavior is kept aligned with the Go router so third-party preflight handling stays unified.
For detailed deployment instructions, see the [Deployment Guide](docs/DEPLOY.en.md). For detailed deployment instructions, see the [Deployment Guide](docs/DEPLOY.en.md).

View File

@@ -1 +1 @@
4.4.2 4.4.6

View File

@@ -75,20 +75,6 @@ func TestResolveExpandedHistoricalAliases(t *testing.T) {
} }
} }
func TestResolveModelHeuristicReasoner(t *testing.T) {
got, ok := ResolveModel(nil, "o3-super")
if !ok || got != "deepseek-v4-pro" {
t.Fatalf("expected heuristic reasoner, got ok=%v model=%q", ok, got)
}
}
func TestResolveModelHeuristicReasonerNoThinking(t *testing.T) {
got, ok := ResolveModel(nil, "o3-super-nothinking")
if !ok || got != "deepseek-v4-pro-nothinking" {
t.Fatalf("expected heuristic reasoner nothinking, got ok=%v model=%q", ok, got)
}
}
func TestResolveModelUnknown(t *testing.T) { func TestResolveModelUnknown(t *testing.T) {
_, ok := ResolveModel(nil, "totally-custom-model") _, ok := ResolveModel(nil, "totally-custom-model")
if ok { if ok {
@@ -96,6 +82,13 @@ func TestResolveModelUnknown(t *testing.T) {
} }
} }
func TestResolveModelUnknownKnownFamilyName(t *testing.T) {
_, ok := ResolveModel(nil, "gpt-5.5-pro-search")
if ok {
t.Fatal("expected unknown known-family model to fail resolve without alias")
}
}
func TestResolveModelRejectsLegacyDeepSeekIDs(t *testing.T) { func TestResolveModelRejectsLegacyDeepSeekIDs(t *testing.T) {
legacyModels := []string{ legacyModels := []string{
"deepseek-chat", "deepseek-chat",
@@ -151,13 +144,6 @@ func TestResolveModelCustomAliasToVision(t *testing.T) {
} }
} }
func TestResolveModelHeuristicVisionIgnoresSearchSuffix(t *testing.T) {
got, ok := ResolveModel(nil, "gemini-vision-search")
if !ok || got != "deepseek-v4-vision" {
t.Fatalf("expected heuristic vision alias to resolve without search variant, got ok=%v model=%q", ok, got)
}
}
func TestClaudeModelsResponsePaginationFields(t *testing.T) { func TestClaudeModelsResponsePaginationFields(t *testing.T) {
resp := ClaudeModelsResponse() resp := ClaudeModelsResponse()
if _, ok := resp["first_id"]; !ok { if _, ok := resp["first_id"]; !ok {

View File

@@ -1,6 +1,9 @@
package config package config
import "strings" import (
"strings"
"time"
)
type ModelInfo struct { type ModelInfo struct {
ID string `json:"id"` ID string `json:"id"`
@@ -9,6 +12,16 @@ type ModelInfo struct {
OwnedBy string `json:"owned_by"` OwnedBy string `json:"owned_by"`
Permission []any `json:"permission,omitempty"` Permission []any `json:"permission,omitempty"`
} }
type OllamaModelInfo struct {
Name string `json:"name"`
Model string `json:"model"`
Size int64 `json:"size"`
ModifiedAt string `json:"modified_at"`
}
type OllamaCapabilitiesModelInfo struct {
ID string `json:"id"`
Capabilities []string `json:"capabilities"`
}
type ModelAliasReader interface { type ModelAliasReader interface {
ModelAliases() map[string]string ModelAliases() map[string]string
@@ -24,8 +37,21 @@ var deepSeekBaseModels = []ModelInfo{
{ID: "deepseek-v4-vision", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}}, {ID: "deepseek-v4-vision", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}},
} }
var DeepSeekModels = appendNoThinkingVariants(deepSeekBaseModels) var OllamaCapabilitiesModels = []OllamaCapabilitiesModelInfo{
{ID: "deepseek-v4-flash", Capabilities: []string{"tools", "thinking"}},
{ID: "deepseek-v4-pro", Capabilities: []string{"tools", "thinking"}},
{ID: "deepseek-v4-flash-search", Capabilities: []string{"tools", "thinking"}},
{ID: "deepseek-v4-pro-search", Capabilities: []string{"tools", "thinking"}},
{ID: "deepseek-v4-vision", Capabilities: []string{"tools", "thinking", "vision"}},
{ID: "deepseek-v4-flash-nothinking", Capabilities: []string{"tools"}},
{ID: "deepseek-v4-pro-nothinking", Capabilities: []string{"tools"}},
{ID: "deepseek-v4-flash-search-nothinking", Capabilities: []string{"tools"}},
{ID: "deepseek-v4-pro-search-nothinking", Capabilities: []string{"tools"}},
{ID: "deepseek-v4-vision-nothinking", Capabilities: []string{"tools", "vision"}},
}
var DeepSeekModels = appendNoThinkingVariants(deepSeekBaseModels)
var OllamaModels = mapToOllamaModels(DeepSeekModels)
var claudeBaseModels = []ModelInfo{ var claudeBaseModels = []ModelInfo{
// Current aliases // Current aliases
{ID: "claude-opus-4-6", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, {ID: "claude-opus-4-6", Object: "model", Created: 1715635200, OwnedBy: "anthropic"},
@@ -214,26 +240,10 @@ func ResolveModel(store ModelAliasReader, requested string) (string, bool) {
return mapped, true return mapped, true
} }
baseModel, noThinking := splitNoThinkingModel(model) baseModel, noThinking := splitNoThinkingModel(model)
resolvedModel, ok := resolveCanonicalModel(aliases, baseModel) if mapped, ok := aliases[baseModel]; ok && IsSupportedDeepSeekModel(mapped) {
if !ok { return withNoThinkingVariant(mapped, noThinking), true
return "", false
}
return withNoThinkingVariant(resolvedModel, noThinking), true
}
func isRetiredHistoricalModel(model string) bool {
switch {
case strings.HasPrefix(model, "claude-1."):
return true
case strings.HasPrefix(model, "claude-2."):
return true
case strings.HasPrefix(model, "claude-instant-"):
return true
case strings.HasPrefix(model, "gpt-3.5"):
return true
default:
return false
} }
return "", false
} }
func lower(s string) string { func lower(s string) string {
@@ -263,6 +273,23 @@ func OpenAIModelByID(store ModelAliasReader, id string) (ModelInfo, bool) {
return ModelInfo{}, false return ModelInfo{}, false
} }
func OllamaModelsResponse() map[string]any {
return map[string]any{"models": OllamaModels}
}
func OllamaModelByID(store ModelAliasReader, id string) (OllamaCapabilitiesModelInfo, bool) {
canonical, ok := ResolveModel(store, id)
if !ok {
return OllamaCapabilitiesModelInfo{}, false
}
for _, model := range OllamaCapabilitiesModels {
if model.ID == canonical {
return model, true
}
}
return OllamaCapabilitiesModelInfo{}, false
}
func ClaudeModelsResponse() map[string]any { func ClaudeModelsResponse() map[string]any {
resp := map[string]any{"object": "list", "data": ClaudeModels} resp := map[string]any{"object": "list", "data": ClaudeModels}
if len(ClaudeModels) > 0 { if len(ClaudeModels) > 0 {
@@ -286,6 +313,23 @@ func appendNoThinkingVariants(models []ModelInfo) []ModelInfo {
} }
return out return out
} }
func mapToOllamaModels(models []ModelInfo) []OllamaModelInfo {
out := make([]OllamaModelInfo, 0, len(models))
for _, model := range models {
var modifiedAt string
if model.Created > 0 {
modifiedAt = time.Unix(model.Created, 0).Format(time.RFC3339)
}
ollamaModel := OllamaModelInfo{
Name: model.ID,
Model: model.ID,
Size: 0,
ModifiedAt: modifiedAt,
}
out = append(out, ollamaModel)
}
return out
}
func splitNoThinkingModel(model string) (string, bool) { func splitNoThinkingModel(model string) (string, bool) {
model = lower(strings.TrimSpace(model)) model = lower(strings.TrimSpace(model))
@@ -315,58 +359,3 @@ func loadModelAliases(store ModelAliasReader) map[string]string {
} }
return aliases return aliases
} }
func resolveCanonicalModel(aliases map[string]string, model string) (string, bool) {
model = lower(strings.TrimSpace(model))
if model == "" {
return "", false
}
if isRetiredHistoricalModel(model) {
return "", false
}
if IsSupportedDeepSeekModel(model) {
return model, true
}
if mapped, ok := aliases[model]; ok && IsSupportedDeepSeekModel(mapped) {
return mapped, true
}
if strings.HasPrefix(model, "deepseek-") {
return "", false
}
knownFamily := false
for _, prefix := range []string{
"gpt-", "o1", "o3", "claude-", "gemini-", "llama-", "qwen-", "mistral-", "command-",
} {
if strings.HasPrefix(model, prefix) {
knownFamily = true
break
}
}
if !knownFamily {
return "", false
}
useVision := strings.Contains(model, "vision")
useReasoner := strings.Contains(model, "reason") ||
strings.Contains(model, "reasoner") ||
strings.HasPrefix(model, "o1") ||
strings.HasPrefix(model, "o3") ||
strings.Contains(model, "opus") ||
strings.Contains(model, "slow") ||
strings.Contains(model, "r1")
useSearch := strings.Contains(model, "search")
switch {
case useVision:
return "deepseek-v4-vision", true
case useReasoner && useSearch:
return "deepseek-v4-pro-search", true
case useReasoner:
return "deepseek-v4-pro", true
case useSearch:
return "deepseek-v4-flash-search", true
default:
return "deepseek-v4-flash", true
}
}

View File

@@ -58,6 +58,11 @@ func RawStreamSampleRoot() string {
} }
func ChatHistoryPath() string { func ChatHistoryPath() string {
// On Vercel, /var/task is read-only at runtime. If no explicit path is set,
// default to /tmp/chat_history.json (the only writable directory).
if IsVercel() && strings.TrimSpace(os.Getenv("DS2API_CHAT_HISTORY_PATH")) == "" {
return "/tmp/chat_history.json"
}
return ResolvePath("DS2API_CHAT_HISTORY_PATH", "data/chat_history.json") return ResolvePath("DS2API_CHAT_HISTORY_PATH", "data/chat_history.json")
} }

View File

@@ -292,7 +292,7 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) {
if !containsStr(prompt, "Search the web") { if !containsStr(prompt, "Search the web") {
t.Fatalf("expected description in prompt") t.Fatalf("expected description in prompt")
} }
if !containsStr(prompt, "<|DSML|tool_calls>") { if !containsStr(prompt, "<DSMLtool_calls>") {
t.Fatalf("expected DSML tool_calls format in prompt") t.Fatalf("expected DSML tool_calls format in prompt")
} }
if !containsStr(prompt, "TOOL CALL FORMAT") { if !containsStr(prompt, "TOOL CALL FORMAT") {

View File

@@ -0,0 +1,58 @@
package ollama
import (
"ds2api/internal/config"
"ds2api/internal/util"
"encoding/json"
"github.com/go-chi/chi/v5"
"log/slog"
"net/http"
)
var WriteJSON = util.WriteJSON
type ConfigReader interface {
ModelAliases() map[string]string
}
type Handler struct {
Store ConfigReader
}
type OllamaModelRequest struct {
Model string `json:"model"`
}
func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/api/version", h.GetVersion)
r.Get("/api/tags", h.ListOllamaModels)
r.Post("/api/show", h.GetOllamaModel)
}
func (h *Handler) GetVersion(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"version":"0.23.1"}`))
}
func (h *Handler) ListOllamaModels(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, config.OllamaModelsResponse())
}
func (h *Handler) GetOllamaModel(w http.ResponseWriter, r *http.Request) {
var payload OllamaModelRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid JSON body: "+err.Error(), http.StatusBadRequest)
return
}
defer func() {
if err := r.Body.Close(); err != nil {
slog.Warn("[ollama] failed to close request body", "error", err)
}
}()
modelID := payload.Model
model, ok := config.OllamaModelByID(h.Store, modelID)
if !ok {
http.Error(w, "Model not found.", http.StatusNotFound)
return
}
WriteJSON(w, http.StatusOK, model)
}

View File

@@ -0,0 +1,127 @@
package ollama
import (
"encoding/json"
"github.com/go-chi/chi/v5"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type ollamaTestSurface struct {
Store ConfigReader
handler *Handler
}
func (h *ollamaTestSurface) apiHandler() *Handler {
if h.handler == nil {
h.handler = &Handler{Store: h.Store}
}
return h.handler
}
func registerOllamaTestRoutes(r chi.Router, h *ollamaTestSurface) {
r.Get("/api/version", h.apiHandler().GetVersion)
r.Get("/api/tags", h.apiHandler().ListOllamaModels)
r.Post("/api/show", h.apiHandler().GetOllamaModel)
}
func TestGetOllamaVersionRoute(t *testing.T) {
h := &ollamaTestSurface{}
r := chi.NewRouter()
registerOllamaTestRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOllamaModelsRoute(t *testing.T) {
h := &ollamaTestSurface{}
r := chi.NewRouter()
registerOllamaTestRoutes(r, h)
req := httptest.NewRequest(http.MethodGet, "/api/tags", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOllamaModelRoute(t *testing.T) {
h := &ollamaTestSurface{}
r := chi.NewRouter()
registerOllamaTestRoutes(r, h)
t.Run("direct", func(t *testing.T) {
body := `{"model":"deepseek-v4-flash"}`
req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("expected valid json body, got err=%v body=%s", err, rec.Body.String())
}
if _, ok := payload["id"]; !ok {
t.Fatalf("expected response has lowercase id field, body=%s", rec.Body.String())
}
if _, ok := payload["ID"]; ok {
t.Fatalf("expected response does not expose uppercase ID field, body=%s", rec.Body.String())
}
})
t.Run("direct_nothinking", func(t *testing.T) {
body := `{"model":"deepseek-v4-flash-nothinking"}`
req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("direct_expert", func(t *testing.T) {
body := `{"model":"deepseek-v4-pro"}`
req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("direct_vision", func(t *testing.T) {
body := `{"model":"deepseek-v4-vision"}`
req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
})
}
func TestGetOllamaModelRouteNotFound(t *testing.T) {
h := &ollamaTestSurface{}
r := chi.NewRouter()
registerOllamaTestRoutes(r, h)
body := `{"model":"not-exists"}`
req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d body=%s", rec.Code, rec.Body.String())
}
}

View File

@@ -127,13 +127,7 @@ func (s *chatStreamRuntime) sendKeepAlive() {
return return
} }
_, _ = s.w.Write([]byte(": keep-alive\n\n")) _, _ = s.w.Write([]byte(": keep-alive\n\n"))
s.sendChunk(openaifmt.BuildChatStreamChunk( _ = s.rc.Flush()
s.completionID,
s.created,
s.model,
[]map[string]any{},
nil,
))
} }
func (s *chatStreamRuntime) sendChunk(v any) { func (s *chatStreamRuntime) sendChunk(v any) {

View File

@@ -10,7 +10,7 @@ import (
"ds2api/internal/promptcompat" "ds2api/internal/promptcompat"
) )
func TestChatStreamKeepAliveEmitsEmptyChoiceDataFrame(t *testing.T) { func TestChatStreamKeepAliveUsesCommentOnly(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
runtime := newChatStreamRuntime( runtime := newChatStreamRuntime(
rec, rec,
@@ -40,18 +40,8 @@ func TestChatStreamKeepAliveEmitsEmptyChoiceDataFrame(t *testing.T) {
if done { if done {
t.Fatalf("keep-alive must not emit [DONE], body=%q", body) t.Fatalf("keep-alive must not emit [DONE], body=%q", body)
} }
if len(frames) != 1 { if len(frames) != 0 {
t.Fatalf("expected one data frame, got %d body=%q", len(frames), body) t.Fatalf("keep-alive must not emit JSON data frames, got %#v body=%q", frames, body)
}
if got := asString(frames[0]["id"]); got != "chatcmpl-test" {
t.Fatalf("expected completion id to be preserved, got %q", got)
}
if got := asString(frames[0]["object"]); got != "chat.completion.chunk" {
t.Fatalf("expected chat chunk object, got %q", got)
}
choices, _ := frames[0]["choices"].([]any)
if len(choices) != 0 {
t.Fatalf("expected empty choices heartbeat, got %#v", choices)
} }
} }

View File

@@ -19,13 +19,15 @@ const BLOCKED_CORS_REQUEST_HEADERS = new Set([
function setCorsHeaders(res, req) { function setCorsHeaders(res, req) {
const origin = asString(readHeader(req, 'origin')); const origin = asString(readHeader(req, 'origin'));
res.setHeader('Access-Control-Allow-Origin', origin || '*'); res.setHeader('Access-Control-Allow-Origin', origin || '*');
if (origin) {
addVaryHeader(res, 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
res.setHeader('Access-Control-Max-Age', '600'); res.setHeader('Access-Control-Max-Age', '600');
res.setHeader( res.setHeader(
'Access-Control-Allow-Headers', 'Access-Control-Allow-Headers',
buildCORSAllowHeaders(req), buildCORSAllowHeaders(req),
); );
addVaryHeader(res, 'Origin');
addVaryHeader(res, 'Access-Control-Request-Headers'); addVaryHeader(res, 'Access-Control-Request-Headers');
if (asString(readHeader(req, 'access-control-request-private-network')).toLowerCase() === 'true') { if (asString(readHeader(req, 'access-control-request-private-network')).toLowerCase() === 'true') {
res.setHeader('Access-Control-Allow-Private-Network', 'true'); res.setHeader('Access-Control-Allow-Private-Network', 'true');

View File

@@ -88,7 +88,7 @@ function isVercelRuntime() {
function isNodeStreamSupportedPath(rawURL) { function isNodeStreamSupportedPath(rawURL) {
const path = extractPathname(rawURL); const path = extractPathname(rawURL);
return path === '/v1/chat/completions'; return path === '/v1/chat/completions' || path === '/chat/completions';
} }
function extractPathname(rawURL) { function extractPathname(rawURL) {

View File

@@ -74,7 +74,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t *
} }
finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false) finalPrompt, _ := buildOpenAIFinalPrompt(messages, tools, "", false)
if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>...</|DSML|tool_calls> block at the end of your response.") { if !strings.Contains(finalPrompt, "Remember: The ONLY valid way to use tools is the <DSMLtool_calls>...</DSMLtool_calls> block at the end of your response.") {
t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt) t.Fatalf("vercel prepare finalPrompt missing final tool-call anchor instruction: %q", finalPrompt)
} }
if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") { if !strings.Contains(finalPrompt, "TOOL CALL FORMAT") {

View File

@@ -22,6 +22,7 @@ import (
"ds2api/internal/httpapi/admin" "ds2api/internal/httpapi/admin"
"ds2api/internal/httpapi/claude" "ds2api/internal/httpapi/claude"
"ds2api/internal/httpapi/gemini" "ds2api/internal/httpapi/gemini"
"ds2api/internal/httpapi/ollama"
"ds2api/internal/httpapi/openai/chat" "ds2api/internal/httpapi/openai/chat"
"ds2api/internal/httpapi/openai/embeddings" "ds2api/internal/httpapi/openai/embeddings"
"ds2api/internal/httpapi/openai/files" "ds2api/internal/httpapi/openai/files"
@@ -68,6 +69,7 @@ func NewApp() (*App, error) {
claudeHandler := &claude.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: chatHandler, ChatHistory: chatHistoryStore} claudeHandler := &claude.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: chatHandler, ChatHistory: chatHistoryStore}
geminiHandler := &gemini.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: chatHandler, ChatHistory: chatHistoryStore} geminiHandler := &gemini.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: chatHandler, ChatHistory: chatHistoryStore}
adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient, OpenAI: chatHandler, ChatHistory: chatHistoryStore} adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient, OpenAI: chatHandler, ChatHistory: chatHistoryStore}
ollamaHandler := &ollama.Handler{Store: store}
webuiHandler := webui.NewHandler() webuiHandler := webui.NewHandler()
r := chi.NewRouter() r := chi.NewRouter()
@@ -112,6 +114,7 @@ func NewApp() (*App, error) {
r.Post("/embeddings", embeddingsHandler.Embeddings) r.Post("/embeddings", embeddingsHandler.Embeddings)
claude.RegisterRoutes(r, claudeHandler) claude.RegisterRoutes(r, claudeHandler)
gemini.RegisterRoutes(r, geminiHandler) gemini.RegisterRoutes(r, geminiHandler)
ollama.RegisterRoutes(r, ollamaHandler)
r.Route("/admin", func(ar chi.Router) { r.Route("/admin", func(ar chi.Router) {
admin.RegisterRoutes(ar, adminHandler) admin.RegisterRoutes(ar, adminHandler)
}) })

View File

@@ -11,46 +11,45 @@ import "strings"
func BuildToolCallInstructions(toolNames []string) string { func BuildToolCallInstructions(toolNames []string) string {
return `TOOL CALL FORMAT — FOLLOW EXACTLY: return `TOOL CALL FORMAT — FOLLOW EXACTLY:
<|DSML|tool_calls> <DSMLtool_calls>
<|DSML|invoke name="TOOL_NAME_HERE"> <DSMLinvoke name="TOOL_NAME_HERE">
<|DSML|parameter name="PARAMETER_NAME"><![CDATA[PARAMETER_VALUE]]></|DSML|parameter> <DSMLparameter name="PARAMETER_NAME"><![CDATA[PARAMETER_VALUE]]></DSMLparameter>
</|DSML|invoke> </DSMLinvoke>
</|DSML|tool_calls> </DSMLtool_calls>
RULES: RULES:
1) Use the <|DSML|tool_calls> wrapper format. 1) Use the <DSMLtool_calls> wrapper format.
2) Put one or more <|DSML|invoke> entries under a single <|DSML|tool_calls> root. 2) Put one or more <DSMLinvoke> entries under a single <DSMLtool_calls> root.
3) Put the tool name in the invoke name attribute: <|DSML|invoke name="TOOL_NAME">. 3) Put the tool name in the invoke name attribute: <DSMLinvoke name="TOOL_NAME">.
4) All string values must use <![CDATA[...]]>, even short ones. This includes code, scripts, file contents, prompts, paths, names, and queries. 4) All string values must use <![CDATA[...]]>, even short ones. This includes code, scripts, file contents, prompts, paths, names, and queries.
5) Every top-level argument must be a <|DSML|parameter name="ARG_NAME">...</|DSML|parameter> node. 5) Every top-level argument must be a <DSMLparameter name="ARG_NAME">...</DSMLparameter> node.
6) Objects use nested XML elements inside the parameter body. Arrays may repeat <item> children. 6) Objects use nested XML elements inside the parameter body. Arrays may repeat <item> children.
7) Numbers, booleans, and null stay plain text. 7) Numbers, booleans, and null stay plain text.
8) Use only the parameter names in the tool schema. Do not invent fields. 8) Use only the parameter names in the tool schema. Do not invent fields.
9) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue. 9) Do NOT wrap XML in markdown fences. Do NOT output explanations, role markers, or internal monologue.
10) If you call a tool, the first non-whitespace characters of that tool block must be exactly <|DSML|tool_calls>. 10) If you call a tool, the first non-whitespace characters of that tool block must be exactly <DSMLtool_calls>.
11) Never omit the opening <|DSML|tool_calls> tag, even if you already plan to close with </|DSML|tool_calls>. 11) Never omit the opening <DSMLtool_calls> tag, even if you already plan to close with </DSMLtool_calls>.
12) Compatibility note: the runtime also accepts the legacy XML tags <tool_calls> / <invoke> / <parameter>, but prefer the DSML-prefixed form above. 12) Compatibility note: the runtime also accepts the legacy XML tags <tool_calls> / <invoke> / <parameter>, but prefer the DSML-prefixed form above.
PARAMETER SHAPES: PARAMETER SHAPES:
- string => <|DSML|parameter name="x"><![CDATA[value]]></|DSML|parameter> - string => <DSMLparameter name="x"><![CDATA[value]]></DSMLparameter>
- object => <|DSML|parameter name="x"><field>...</field></|DSML|parameter> - object => <DSMLparameter name="x"><field>...</field></DSMLparameter>
- array => <|DSML|parameter name="x"><item>...</item><item>...</item></|DSML|parameter> - array => <DSMLparameter name="x"><item>...</item><item>...</item></DSMLparameter>
- number/bool/null => <|DSML|parameter name="x">plain_text</|DSML|parameter> - number/bool/null => <DSMLparameter name="x">plain_text</DSMLparameter>
【WRONG — Do NOT do these】: 【WRONG — Do NOT do these】:
Wrong 1 — mixed text after XML: Wrong 1 — mixed text after XML:
<|DSML|tool_calls>...</|DSML|tool_calls> I hope this helps. <DSMLtool_calls>...</DSMLtool_calls> I hope this helps.
Wrong 2 — Markdown code fences: Wrong 2 — Markdown code fences:
` + "```xml" + ` ` + "```xml" + `
<|DSML|tool_calls>...</|DSML|tool_calls> <DSMLtool_calls>...</DSMLtool_calls>
` + "```" + ` ` + "```" + `
Wrong 3 — missing opening wrapper: Wrong 3 — missing opening wrapper:
<|DSML|invoke name="TOOL_NAME">...</|DSML|invoke> <DSMLinvoke name="TOOL_NAME">...</DSMLinvoke>
</|DSML|tool_calls> </DSMLtool_calls>
Remember: The ONLY valid way to use tools is the <|DSML|tool_calls>...</|DSML|tool_calls> block at the end of your response.
Remember: The ONLY valid way to use tools is the <DSMLtool_calls>...</DSMLtool_calls> block at the end of your response.
` + buildCorrectToolExamples(toolNames) ` + buildCorrectToolExamples(toolNames)
} }
@@ -141,21 +140,21 @@ func firstScriptExample(names []string) (promptToolExample, bool) {
func renderToolExampleBlock(calls []promptToolExample) string { func renderToolExampleBlock(calls []promptToolExample) string {
var b strings.Builder var b strings.Builder
b.WriteString("<|DSML|tool_calls>\n") b.WriteString("<DSMLtool_calls>\n")
for _, call := range calls { for _, call := range calls {
b.WriteString(` <|DSML|invoke name="`) b.WriteString(` <DSMLinvoke name="`)
b.WriteString(call.name) b.WriteString(call.name)
b.WriteString(`">` + "\n") b.WriteString(`">` + "\n")
b.WriteString(indentPromptParameters(call.params, " ")) b.WriteString(indentPromptParameters(call.params, " "))
b.WriteString("\n </|DSML|invoke>\n") b.WriteString("\n </DSMLinvoke>\n")
} }
b.WriteString("</|DSML|tool_calls>") b.WriteString("</DSMLtool_calls>")
return b.String() return b.String()
} }
func indentPromptParameters(body, indent string) string { func indentPromptParameters(body, indent string) string {
if strings.TrimSpace(body) == "" { if strings.TrimSpace(body) == "" {
return indent + `<|DSML|parameter name="content"></|DSML|parameter>` return indent + `<DSMLparameter name="content"></DSMLparameter>`
} }
lines := strings.Split(body, "\n") lines := strings.Split(body, "\n")
for i, line := range lines { for i, line := range lines {
@@ -169,7 +168,7 @@ func indentPromptParameters(body, indent string) string {
} }
func wrapParameter(name, inner string) string { func wrapParameter(name, inner string) string {
return `<|DSML|parameter name="` + name + `">` + inner + `</|DSML|parameter>` return `<DSMLparameter name="` + name + `">` + inner + `</DSMLparameter>`
} }
func exampleBasicParams(name string) (string, bool) { func exampleBasicParams(name string) (string, bool) {
@@ -195,7 +194,7 @@ func exampleBasicParams(name string) (string, bool) {
case "Edit": case "Edit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + wrapParameter("old_string", promptCDATA("foo")) + "\n" + wrapParameter("new_string", promptCDATA("bar")), true return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + wrapParameter("old_string", promptCDATA("foo")) + "\n" + wrapParameter("new_string", promptCDATA("bar")), true
case "MultiEdit": case "MultiEdit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></|DSML|parameter>`, true return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<DSMLparameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></DSMLparameter>`, true
} }
return "", false return "", false
} }
@@ -203,11 +202,11 @@ func exampleBasicParams(name string) (string, bool) {
func exampleNestedParams(name string) (string, bool) { func exampleNestedParams(name string) (string, bool) {
switch strings.TrimSpace(name) { switch strings.TrimSpace(name) {
case "MultiEdit": case "MultiEdit":
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<|DSML|parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></|DSML|parameter>`, true return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<DSMLparameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></DSMLparameter>`, true
case "Task": case "Task":
return wrapParameter("description", promptCDATA("Investigate flaky tests")) + "\n" + wrapParameter("prompt", promptCDATA("Run targeted tests and summarize failures")), true return wrapParameter("description", promptCDATA("Investigate flaky tests")) + "\n" + wrapParameter("prompt", promptCDATA("Run targeted tests and summarize failures")), true
case "ask_followup_question": case "ask_followup_question":
return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<|DSML|parameter name="follow_up"><item><text>` + promptCDATA("Option A") + `</text></item><item><text>` + promptCDATA("Option B") + `</text></item></|DSML|parameter>`, true return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `<DSMLparameter name="follow_up"><item><text>` + promptCDATA("Option A") + `</text></item><item><text>` + promptCDATA("Option B") + `</text></item></DSMLparameter>`, true
} }
return "", false return "", false
} }

View File

@@ -7,20 +7,20 @@ import (
func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) { func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) {
out := BuildToolCallInstructions([]string{"exec_command"}) out := BuildToolCallInstructions([]string{"exec_command"})
if !strings.Contains(out, `<|DSML|invoke name="exec_command">`) { if !strings.Contains(out, `<DSMLinvoke name="exec_command">`) {
t.Fatalf("expected exec_command in examples, got: %s", out) t.Fatalf("expected exec_command in examples, got: %s", out)
} }
if !strings.Contains(out, `<|DSML|parameter name="cmd"><![CDATA[pwd]]></|DSML|parameter>`) { if !strings.Contains(out, `<DSMLparameter name="cmd"><![CDATA[pwd]]></DSMLparameter>`) {
t.Fatalf("expected cmd parameter example for exec_command, got: %s", out) t.Fatalf("expected cmd parameter example for exec_command, got: %s", out)
} }
} }
func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T) { func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T) {
out := BuildToolCallInstructions([]string{"execute_command"}) out := BuildToolCallInstructions([]string{"execute_command"})
if !strings.Contains(out, `<|DSML|invoke name="execute_command">`) { if !strings.Contains(out, `<DSMLinvoke name="execute_command">`) {
t.Fatalf("expected execute_command in examples, got: %s", out) t.Fatalf("expected execute_command in examples, got: %s", out)
} }
if !strings.Contains(out, `<|DSML|parameter name="command"><![CDATA[pwd]]></|DSML|parameter>`) { if !strings.Contains(out, `<DSMLparameter name="command"><![CDATA[pwd]]></DSMLparameter>`) {
t.Fatalf("expected command parameter example for execute_command, got: %s", out) t.Fatalf("expected command parameter example for execute_command, got: %s", out)
} }
} }
@@ -34,20 +34,20 @@ func TestBuildToolCallInstructions_BashUsesCommandAndDescriptionExamples(t *test
sawDescription := false sawDescription := false
for _, block := range blocks { for _, block := range blocks {
if !strings.Contains(block, `<|DSML|parameter name="command">`) { if !strings.Contains(block, `<DSMLparameter name="command">`) {
t.Fatalf("expected every Bash example to use command parameter, got: %s", block) t.Fatalf("expected every Bash example to use command parameter, got: %s", block)
} }
if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { if strings.Contains(block, `<DSMLparameter name="path">`) || strings.Contains(block, `<DSMLparameter name="content">`) {
t.Fatalf("expected Bash examples not to use file write parameters, got: %s", block) t.Fatalf("expected Bash examples not to use file write parameters, got: %s", block)
} }
if strings.Contains(block, `<|DSML|parameter name="description">`) { if strings.Contains(block, `<DSMLparameter name="description">`) {
sawDescription = true sawDescription = true
} }
} }
if !sawDescription { if !sawDescription {
t.Fatalf("expected Bash long-script example to include description, got: %s", out) t.Fatalf("expected Bash long-script example to include description, got: %s", out)
} }
if strings.Contains(out, `<|DSML|invoke name="Read">`) { if strings.Contains(out, `<DSMLinvoke name="Read">`) {
t.Fatalf("expected examples to avoid unavailable hard-coded Read tool, got: %s", out) t.Fatalf("expected examples to avoid unavailable hard-coded Read tool, got: %s", out)
} }
} }
@@ -60,10 +60,10 @@ func TestBuildToolCallInstructions_ExecuteCommandLongScriptUsesCommand(t *testin
} }
for _, block := range blocks { for _, block := range blocks {
if !strings.Contains(block, `<|DSML|parameter name="command">`) { if !strings.Contains(block, `<DSMLparameter name="command">`) {
t.Fatalf("expected execute_command examples to use command parameter, got: %s", block) t.Fatalf("expected execute_command examples to use command parameter, got: %s", block)
} }
if strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { if strings.Contains(block, `<DSMLparameter name="path">`) || strings.Contains(block, `<DSMLparameter name="content">`) {
t.Fatalf("expected execute_command examples not to use file write parameters, got: %s", block) t.Fatalf("expected execute_command examples not to use file write parameters, got: %s", block)
} }
} }
@@ -80,10 +80,10 @@ func TestBuildToolCallInstructions_ExecCommandLongScriptUsesCmd(t *testing.T) {
} }
for _, block := range blocks { for _, block := range blocks {
if !strings.Contains(block, `<|DSML|parameter name="cmd">`) { if !strings.Contains(block, `<DSMLparameter name="cmd">`) {
t.Fatalf("expected exec_command examples to use cmd parameter, got: %s", block) t.Fatalf("expected exec_command examples to use cmd parameter, got: %s", block)
} }
if strings.Contains(block, `<|DSML|parameter name="command">`) || strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter name="content">`) { if strings.Contains(block, `<DSMLparameter name="command">`) || strings.Contains(block, `<DSMLparameter name="path">`) || strings.Contains(block, `<DSMLparameter name="content">`) {
t.Fatalf("expected exec_command examples not to use command or file write parameters, got: %s", block) t.Fatalf("expected exec_command examples not to use command or file write parameters, got: %s", block)
} }
} }
@@ -100,10 +100,10 @@ func TestBuildToolCallInstructions_WriteUsesFilePathAndContent(t *testing.T) {
} }
for _, block := range blocks { for _, block := range blocks {
if !strings.Contains(block, `<|DSML|parameter name="file_path">`) || !strings.Contains(block, `<|DSML|parameter name="content">`) { if !strings.Contains(block, `<DSMLparameter name="file_path">`) || !strings.Contains(block, `<DSMLparameter name="content">`) {
t.Fatalf("expected Write examples to use file_path and content, got: %s", block) t.Fatalf("expected Write examples to use file_path and content, got: %s", block)
} }
if strings.Contains(block, `<|DSML|parameter name="path">`) { if strings.Contains(block, `<DSMLparameter name="path">`) {
t.Fatalf("expected Write examples not to use path, got: %s", block) t.Fatalf("expected Write examples not to use path, got: %s", block)
} }
} }
@@ -111,7 +111,7 @@ func TestBuildToolCallInstructions_WriteUsesFilePathAndContent(t *testing.T) {
func TestBuildToolCallInstructions_AnchorsMissingOpeningWrapperFailureMode(t *testing.T) { func TestBuildToolCallInstructions_AnchorsMissingOpeningWrapperFailureMode(t *testing.T) {
out := BuildToolCallInstructions([]string{"read_file"}) out := BuildToolCallInstructions([]string{"read_file"})
if !strings.Contains(out, "Never omit the opening <|DSML|tool_calls> tag") { if !strings.Contains(out, "Never omit the opening <DSMLtool_calls> tag") {
t.Fatalf("expected explicit missing-opening-tag warning, got: %s", out) t.Fatalf("expected explicit missing-opening-tag warning, got: %s", out)
} }
if !strings.Contains(out, "Wrong 3 — missing opening wrapper") { if !strings.Contains(out, "Wrong 3 — missing opening wrapper") {
@@ -120,7 +120,7 @@ func TestBuildToolCallInstructions_AnchorsMissingOpeningWrapperFailureMode(t *te
} }
func findInvokeBlocks(text, name string) []string { func findInvokeBlocks(text, name string) []string {
open := `<|DSML|invoke name="` + name + `">` open := `<DSMLinvoke name="` + name + `">`
remaining := text remaining := text
blocks := []string{} blocks := []string{}
for { for {
@@ -129,11 +129,11 @@ func findInvokeBlocks(text, name string) []string {
return blocks return blocks
} }
remaining = remaining[start:] remaining = remaining[start:]
end := strings.Index(remaining, `</|DSML|invoke>`) end := strings.Index(remaining, `</DSMLinvoke>`)
if end < 0 { if end < 0 {
return blocks return blocks
} }
end += len(`</|DSML|invoke>`) end += len(`</DSMLinvoke>`)
blocks = append(blocks, remaining[:end]) blocks = append(blocks, remaining[:end])
remaining = remaining[end:] remaining = remaining[end:]
} }

View File

@@ -17,11 +17,10 @@ func rewriteDSMLToolMarkupOutsideIgnored(text string) string {
if text == "" { if text == "" {
return "" return ""
} }
lower := strings.ToLower(text)
var b strings.Builder var b strings.Builder
b.Grow(len(text)) b.Grow(len(text))
for i := 0; i < len(text); { for i := 0; i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(text, lower, i) next, advanced, blocked := skipXMLIgnoredSection(text, i)
if blocked { if blocked {
b.WriteString(text[i:]) b.WriteString(text[i:])
break break

View File

@@ -145,7 +145,6 @@ func SanitizeLooseCDATA(text string) string {
return "" return ""
} }
lower := strings.ToLower(text)
const openMarker = "<![cdata[" const openMarker = "<![cdata["
const closeMarker = "]]>" const closeMarker = "]]>"
@@ -154,17 +153,16 @@ func SanitizeLooseCDATA(text string) string {
changed := false changed := false
pos := 0 pos := 0
for pos < len(text) { for pos < len(text) {
startRel := strings.Index(lower[pos:], openMarker) start := indexASCIIFold(text, pos, openMarker)
if startRel < 0 { if start < 0 {
b.WriteString(text[pos:]) b.WriteString(text[pos:])
break break
} }
start := pos + startRel
contentStart := start + len(openMarker) contentStart := start + len(openMarker)
b.WriteString(text[pos:start]) b.WriteString(text[pos:start])
if endRel := strings.Index(lower[contentStart:], closeMarker); endRel >= 0 { if endRel := indexASCIIFold(text, contentStart, closeMarker); endRel >= 0 {
end := contentStart + endRel + len(closeMarker) end := endRel + len(closeMarker)
b.WriteString(text[start:end]) b.WriteString(text[start:end])
pos = end pos = end
continue continue

View File

@@ -212,17 +212,16 @@ func firstFenceMarkerIndex(line string) int {
} }
func updateCDATAStateForStrip(inCDATA bool, cdataFenceMarker, line string) (bool, string) { func updateCDATAStateForStrip(inCDATA bool, cdataFenceMarker, line string) (bool, string) {
lower := strings.ToLower(line)
pos := 0 pos := 0
state := inCDATA state := inCDATA
fenceMarker := cdataFenceMarker fenceMarker := cdataFenceMarker
lineForFence := line lineForFence := line
if !state { if !state {
start := strings.Index(lower[pos:], "<![cdata[") start := indexASCIIFold(line, pos, "<![cdata[")
if start < 0 { if start < 0 {
return false, "" return false, ""
} }
pos += start + len("<![cdata[") pos = start + len("<![cdata[")
state = true state = true
lineForFence = line[pos:] lineForFence = line[pos:]
} }
@@ -239,24 +238,23 @@ func updateCDATAStateForStrip(inCDATA bool, cdataFenceMarker, line string) (bool
fenceMarker = "" fenceMarker = ""
} }
for pos < len(lower) { for pos < len(line) {
end := strings.Index(lower[pos:], "]]>") endPos := indexASCIIFold(line, pos, "]]>")
if end < 0 { if endPos < 0 {
return true, fenceMarker return true, fenceMarker
} }
endPos := pos + end
pos = endPos + len("]]>") pos = endPos + len("]]>")
if fenceMarker != "" { if fenceMarker != "" {
continue continue
} }
if cdataEndLooksStructural(lower, pos) || strings.TrimSpace(lower[pos:]) == "" { if cdataEndLooksStructural(line, pos) || strings.TrimSpace(line[pos:]) == "" {
state = false state = false
for pos < len(lower) { for pos < len(line) {
start := strings.Index(lower[pos:], "<![cdata[") start := indexASCIIFold(line, pos, "<![cdata[")
if start < 0 { if start < 0 {
return false, "" return false, ""
} }
pos += start + len("<![cdata[") pos = start + len("<![cdata[")
state = true state = true
trimmedTail := strings.TrimLeft(line[pos:], " \t") trimmedTail := strings.TrimLeft(line[pos:], " \t")
if marker, ok := parseFenceOpen(trimmedTail); ok { if marker, ok := parseFenceOpen(trimmedTail); ok {

View File

@@ -141,10 +141,9 @@ func findXMLElementBlocks(text, tag string) []xmlElementBlock {
} }
func findXMLStartTagOutsideCDATA(text, tag string, from int) (start, bodyStart int, attrs string, ok bool) { func findXMLStartTagOutsideCDATA(text, tag string, from int) (start, bodyStart int, attrs string, ok bool) {
lower := strings.ToLower(text)
target := "<" + strings.ToLower(tag) target := "<" + strings.ToLower(tag)
for i := maxInt(from, 0); i < len(text); { for i := maxInt(from, 0); i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(text, lower, i) next, advanced, blocked := skipXMLIgnoredSection(text, i)
if blocked { if blocked {
return -1, -1, "", false return -1, -1, "", false
} }
@@ -152,7 +151,7 @@ func findXMLStartTagOutsideCDATA(text, tag string, from int) (start, bodyStart i
i = next i = next
continue continue
} }
if strings.HasPrefix(lower[i:], target) && hasXMLTagBoundary(text, i+len(target)) { if hasASCIIPrefixFoldAt(text, i, target) && hasXMLTagBoundary(text, i+len(target)) {
end := findXMLTagEnd(text, i+len(target)) end := findXMLTagEnd(text, i+len(target))
if end < 0 { if end < 0 {
return -1, -1, "", false return -1, -1, "", false
@@ -165,12 +164,11 @@ func findXMLStartTagOutsideCDATA(text, tag string, from int) (start, bodyStart i
} }
func findMatchingXMLEndTagOutsideCDATA(text, tag string, from int) (closeStart, closeEnd int, ok bool) { func findMatchingXMLEndTagOutsideCDATA(text, tag string, from int) (closeStart, closeEnd int, ok bool) {
lower := strings.ToLower(text)
openTarget := "<" + strings.ToLower(tag) openTarget := "<" + strings.ToLower(tag)
closeTarget := "</" + strings.ToLower(tag) closeTarget := "</" + strings.ToLower(tag)
depth := 1 depth := 1
for i := maxInt(from, 0); i < len(text); { for i := maxInt(from, 0); i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(text, lower, i) next, advanced, blocked := skipXMLIgnoredSection(text, i)
if blocked { if blocked {
return -1, -1, false return -1, -1, false
} }
@@ -178,7 +176,7 @@ func findMatchingXMLEndTagOutsideCDATA(text, tag string, from int) (closeStart,
i = next i = next
continue continue
} }
if strings.HasPrefix(lower[i:], closeTarget) && hasXMLTagBoundary(text, i+len(closeTarget)) { if hasASCIIPrefixFoldAt(text, i, closeTarget) && hasXMLTagBoundary(text, i+len(closeTarget)) {
end := findXMLTagEnd(text, i+len(closeTarget)) end := findXMLTagEnd(text, i+len(closeTarget))
if end < 0 { if end < 0 {
return -1, -1, false return -1, -1, false
@@ -190,7 +188,7 @@ func findMatchingXMLEndTagOutsideCDATA(text, tag string, from int) (closeStart,
i = end + 1 i = end + 1
continue continue
} }
if strings.HasPrefix(lower[i:], openTarget) && hasXMLTagBoundary(text, i+len(openTarget)) { if hasASCIIPrefixFoldAt(text, i, openTarget) && hasXMLTagBoundary(text, i+len(openTarget)) {
end := findXMLTagEnd(text, i+len(openTarget)) end := findXMLTagEnd(text, i+len(openTarget))
if end < 0 { if end < 0 {
return -1, -1, false return -1, -1, false
@@ -206,16 +204,19 @@ func findMatchingXMLEndTagOutsideCDATA(text, tag string, from int) (closeStart,
return -1, -1, false return -1, -1, false
} }
func skipXMLIgnoredSection(text, lower string, i int) (next int, advanced bool, blocked bool) { func skipXMLIgnoredSection(text string, i int) (next int, advanced bool, blocked bool) {
if i < 0 || i >= len(text) {
return i, false, false
}
switch { switch {
case strings.HasPrefix(lower[i:], "<![cdata["): case hasASCIIPrefixFoldAt(text, i, "<![cdata["):
end := findToolCDATAEnd(text, lower, i+len("<![cdata[")) end := findToolCDATAEnd(text, i+len("<![cdata["))
if end < 0 { if end < 0 {
return 0, false, true return 0, false, true
} }
return end + len("]]>"), true, false return end + len("]]>"), true, false
case strings.HasPrefix(lower[i:], "<!--"): case strings.HasPrefix(text[i:], "<!--"):
end := strings.Index(lower[i+len("<!--"):], "-->") end := strings.Index(text[i+len("<!--"):], "-->")
if end < 0 { if end < 0 {
return 0, false, true return 0, false, true
} }
@@ -225,14 +226,50 @@ func skipXMLIgnoredSection(text, lower string, i int) (next int, advanced bool,
} }
} }
func findToolCDATAEnd(text, lower string, from int) int { func hasASCIIPrefixFoldAt(text string, start int, prefix string) bool {
if from < 0 || from > len(text) { if start < 0 || len(text)-start < len(prefix) {
return false
}
for j := 0; j < len(prefix); j++ {
if asciiLower(text[start+j]) != asciiLower(prefix[j]) {
return false
}
}
return true
}
func asciiLower(b byte) byte {
if b >= 'A' && b <= 'Z' {
return b + ('a' - 'A')
}
return b
}
// indexASCIIFold returns the absolute byte position in s where substr (ASCII-only) is
// found case-insensitively, scanning forward from start. Returns -1 if not found.
// Unlike strings.Index on a lowercased copy, this does not allocate or risk byte-length
// mismatch when non-ASCII runes change width under case folding.
func indexASCIIFold(s string, start int, substr string) int {
if start < 0 || len(s)-start < len(substr) {
return -1
}
end := len(s) - len(substr) + 1
for i := start; i < end; i++ {
if hasASCIIPrefixFoldAt(s, i, substr) {
return i
}
}
return -1
}
func findToolCDATAEnd(text string, from int) int {
if from < 0 || from >= len(text) {
return -1 return -1
} }
const closeMarker = "]]>" const closeMarker = "]]>"
firstNonFenceEnd := -1 firstNonFenceEnd := -1
for searchFrom := from; searchFrom < len(text); { for searchFrom := from; searchFrom < len(text); {
rel := strings.Index(lower[searchFrom:], closeMarker) rel := strings.Index(text[searchFrom:], closeMarker)
if rel < 0 { if rel < 0 {
break break
} }
@@ -241,27 +278,28 @@ func findToolCDATAEnd(text, lower string, from int) int {
if cdataOffsetIsInsideMarkdownFence(text[from:end]) { if cdataOffsetIsInsideMarkdownFence(text[from:end]) {
continue continue
} }
if cdataEndLooksStructural(text, searchFrom) {
return end
}
if firstNonFenceEnd < 0 { if firstNonFenceEnd < 0 {
firstNonFenceEnd = end firstNonFenceEnd = end
} }
if cdataEndLooksStructural(lower, searchFrom) {
return end
}
} }
return firstNonFenceEnd return firstNonFenceEnd
} }
func cdataEndLooksStructural(lower string, after int) bool { func cdataEndLooksStructural(text string, after int) bool {
for after < len(lower) { for after < len(text) {
switch lower[after] { switch {
case ' ', '\t', '\r', '\n': case text[after] == ' ' || text[after] == '\t' || text[after] == '\r' || text[after] == '\n':
after++ after++
continue case after+1 < len(text) && text[after] == '<' && text[after+1] == '/':
return true
default: default:
return false
} }
break
} }
return strings.HasPrefix(lower[after:], "</") return false
} }
func cdataOffsetIsInsideMarkdownFence(fragment string) bool { func cdataOffsetIsInsideMarkdownFence(fragment string) bool {

View File

@@ -28,9 +28,8 @@ type ToolMarkupTag struct {
} }
func ContainsToolMarkupSyntaxOutsideIgnored(text string) (hasDSML, hasCanonical bool) { func ContainsToolMarkupSyntaxOutsideIgnored(text string) (hasDSML, hasCanonical bool) {
lower := strings.ToLower(text)
for i := 0; i < len(text); { for i := 0; i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(text, lower, i) next, advanced, blocked := skipXMLIgnoredSection(text, i)
if blocked { if blocked {
return hasDSML, hasCanonical return hasDSML, hasCanonical
} }
@@ -56,9 +55,8 @@ func ContainsToolMarkupSyntaxOutsideIgnored(text string) (hasDSML, hasCanonical
} }
func ContainsToolCallWrapperSyntaxOutsideIgnored(text string) (hasDSML, hasCanonical bool) { func ContainsToolCallWrapperSyntaxOutsideIgnored(text string) (hasDSML, hasCanonical bool) {
lower := strings.ToLower(text)
for i := 0; i < len(text); { for i := 0; i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(text, lower, i) next, advanced, blocked := skipXMLIgnoredSection(text, i)
if blocked { if blocked {
return hasDSML, hasCanonical return hasDSML, hasCanonical
} }
@@ -88,9 +86,8 @@ func ContainsToolCallWrapperSyntaxOutsideIgnored(text string) (hasDSML, hasCanon
} }
func FindToolMarkupTagOutsideIgnored(text string, start int) (ToolMarkupTag, bool) { func FindToolMarkupTagOutsideIgnored(text string, start int) (ToolMarkupTag, bool) {
lower := strings.ToLower(text)
for i := maxInt(start, 0); i < len(text); { for i := maxInt(start, 0); i < len(text); {
next, advanced, blocked := skipXMLIgnoredSection(text, lower, i) next, advanced, blocked := skipXMLIgnoredSection(text, i)
if blocked { if blocked {
return ToolMarkupTag{}, false return ToolMarkupTag{}, false
} }
@@ -107,7 +104,7 @@ func FindToolMarkupTagOutsideIgnored(text string, start int) (ToolMarkupTag, boo
} }
func FindMatchingToolMarkupClose(text string, open ToolMarkupTag) (ToolMarkupTag, bool) { func FindMatchingToolMarkupClose(text string, open ToolMarkupTag) (ToolMarkupTag, bool) {
if text == "" || open.Name == "" || open.Closing { if text == "" || open.Name == "" || open.Closing || open.End >= len(text) {
return ToolMarkupTag{}, false return ToolMarkupTag{}, false
} }
depth := 1 depth := 1
@@ -137,7 +134,6 @@ func scanToolMarkupTagAt(text string, start int) (ToolMarkupTag, bool) {
if start < 0 || start >= len(text) || text[start] != '<' { if start < 0 || start >= len(text) || text[start] != '<' {
return ToolMarkupTag{}, false return ToolMarkupTag{}, false
} }
lower := strings.ToLower(text)
i := start + 1 i := start + 1
for i < len(text) && text[i] == '<' { for i < len(text) && text[i] == '<' {
i++ i++
@@ -147,8 +143,8 @@ func scanToolMarkupTagAt(text string, start int) (ToolMarkupTag, bool) {
closing = true closing = true
i++ i++
} }
i, dsmlLike := consumeToolMarkupNamePrefix(lower, text, i) i, dsmlLike := consumeToolMarkupNamePrefix(text, i)
name, nameLen := matchToolMarkupName(lower, i, dsmlLike) name, nameLen := matchToolMarkupName(text, i, dsmlLike)
if nameLen == 0 { if nameLen == 0 {
return ToolMarkupTag{}, false return ToolMarkupTag{}, false
} }
@@ -191,7 +187,6 @@ func IsPartialToolMarkupTagPrefix(text string) bool {
if text == "" || text[0] != '<' || strings.Contains(text, ">") { if text == "" || text[0] != '<' || strings.Contains(text, ">") {
return false return false
} }
lower := strings.ToLower(text)
i := 1 i := 1
for i < len(text) && text[i] == '<' { for i < len(text) && text[i] == '<' {
i++ i++
@@ -206,13 +201,13 @@ func IsPartialToolMarkupTagPrefix(text string) bool {
if i == len(text) { if i == len(text) {
return true return true
} }
if hasToolMarkupNamePrefix(lower[i:]) { if hasToolMarkupNamePrefix(text, i) {
return true return true
} }
if strings.HasPrefix("dsml", lower[i:]) { if hasASCIIPartialPrefixFoldAt(text, i, "dsml") {
return true return true
} }
next, ok := consumeToolMarkupNamePrefixOnce(lower, text, i) next, ok := consumeToolMarkupNamePrefixOnce(text, i)
if !ok { if !ok {
return false return false
} }
@@ -221,10 +216,10 @@ func IsPartialToolMarkupTagPrefix(text string) bool {
return false return false
} }
func consumeToolMarkupNamePrefix(lower, text string, idx int) (int, bool) { func consumeToolMarkupNamePrefix(text string, idx int) (int, bool) {
dsmlLike := false dsmlLike := false
for { for {
next, ok := consumeToolMarkupNamePrefixOnce(lower, text, idx) next, ok := consumeToolMarkupNamePrefixOnce(text, idx)
if !ok { if !ok {
return idx, dsmlLike return idx, dsmlLike
} }
@@ -233,14 +228,14 @@ func consumeToolMarkupNamePrefix(lower, text string, idx int) (int, bool) {
} }
} }
func consumeToolMarkupNamePrefixOnce(lower, text string, idx int) (int, bool) { func consumeToolMarkupNamePrefixOnce(text string, idx int) (int, bool) {
if next, ok := consumeToolMarkupPipe(text, idx); ok { if next, ok := consumeToolMarkupPipe(text, idx); ok {
return next, true return next, true
} }
if idx < len(text) && (text[idx] == ' ' || text[idx] == '\t' || text[idx] == '\r' || text[idx] == '\n') { if idx < len(text) && (text[idx] == ' ' || text[idx] == '\t' || text[idx] == '\r' || text[idx] == '\n') {
return idx + 1, true return idx + 1, true
} }
if strings.HasPrefix(lower[idx:], "dsml") { if hasASCIIPrefixFoldAt(text, idx, "dsml") {
next := idx + len("dsml") next := idx + len("dsml")
if next < len(text) && (text[next] == '-' || text[next] == '_') { if next < len(text) && (text[next] == '-' || text[next] == '_') {
next++ next++
@@ -250,21 +245,37 @@ func consumeToolMarkupNamePrefixOnce(lower, text string, idx int) (int, bool) {
return idx, false return idx, false
} }
func hasToolMarkupNamePrefix(lowerTail string) bool { func hasASCIIPartialPrefixFoldAt(text string, start int, prefix string) bool {
remain := len(text) - start
if remain <= 0 || remain > len(prefix) {
return false
}
for j := 0; j < remain; j++ {
if asciiLower(text[start+j]) != asciiLower(prefix[j]) {
return false
}
}
return true
}
func hasToolMarkupNamePrefix(text string, start int) bool {
for _, name := range toolMarkupNames { for _, name := range toolMarkupNames {
if strings.HasPrefix(lowerTail, name.raw) || strings.HasPrefix(name.raw, lowerTail) { if hasASCIIPrefixFoldAt(text, start, name.raw) {
return true
}
if hasASCIIPartialPrefixFoldAt(text, start, name.raw) {
return true return true
} }
} }
return false return false
} }
func matchToolMarkupName(lower string, start int, dsmlLike bool) (string, int) { func matchToolMarkupName(text string, start int, dsmlLike bool) (string, int) {
for _, name := range toolMarkupNames { for _, name := range toolMarkupNames {
if name.dsmlOnly && !dsmlLike { if name.dsmlOnly && !dsmlLike {
continue continue
} }
if strings.HasPrefix(lower[start:], name.raw) { if hasASCIIPrefixFoldAt(text, start, name.raw) {
return name.canonical, len(name.raw) return name.canonical, len(name.raw)
} }
} }

View File

@@ -892,3 +892,139 @@ func TestParseToolCallsSkipsProseMentionOfSameWrapperVariant(t *testing.T) {
t.Fatalf("expected command to parse, got %q", got) t.Fatalf("expected command to parse, got %q", got)
} }
} }
func TestTurkishILowercaseMapping(t *testing.T) {
tests := []struct {
name string
text string
start int
wantOk bool
wantName string
}{
{"turkish_i_at_name_start", "İ<tool>", 0, false, ""},
{"turkish_i_at_name_end", "<toolİ>", 0, false, ""},
{"turkish_i_before_tag", "İ<tool>", 0, false, ""},
{"normal_tool_calls", "<tool_calls>", 0, true, "tool_calls"},
{"normal_invoke", "<invoke name=\"test\">", 0, true, "invoke"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := FindToolMarkupTagOutsideIgnored(tt.text, tt.start)
if ok != tt.wantOk {
t.Errorf("FindToolMarkupTagOutsideIgnored(%q, %d) ok = %v, want %v", tt.text, tt.start, ok, tt.wantOk)
return
}
if ok && got.Name != tt.wantName {
t.Errorf("FindToolMarkupTagOutsideIgnored(%q, %d) name = %q, want %q", tt.text, tt.start, got.Name, tt.wantName)
}
})
}
}
func TestSkipXMLIgnoredSectionBoundaryConditions(t *testing.T) {
text := "hello"
tests := []struct {
name string
i int
wantNext int
wantAdv bool
wantBlk bool
}{
{"valid_index", 2, 2, false, false},
{"at_end_equal_len", 5, 5, false, false},
{"beyond_end", 6, 6, false, false},
{"negative", -1, -1, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
next, adv, blk := skipXMLIgnoredSection(text, tt.i)
if next != tt.wantNext || adv != tt.wantAdv || blk != tt.wantBlk {
t.Errorf("skipXMLIgnoredSection(%q, %d) = (%d, %v, %v), want (%d, %v, %v)",
text, tt.i, next, adv, blk, tt.wantNext, tt.wantAdv, tt.wantBlk)
}
})
}
}
func TestSkipXMLIgnoredSectionCommentWithUnicodeKeepsByteOffset(t *testing.T) {
text := "<!-- İ -->x<tool_calls>"
next, adv, blk := skipXMLIgnoredSection(text, 0)
if blk || !adv {
t.Fatalf("skipXMLIgnoredSection() = (%d, %v, %v), want advanced unblocked comment", next, adv, blk)
}
if want := len("<!-- İ -->"); next != want {
t.Fatalf("skipXMLIgnoredSection() next = %d, want %d", next, want)
}
}
func TestSkipXMLIgnoredSectionMatchesCDATAWithoutAllocatingTail(t *testing.T) {
text := "<![cDaTa[<tool_calls>]]><tool_calls>"
next, adv, blk := skipXMLIgnoredSection(text, 0)
if blk || !adv {
t.Fatalf("skipXMLIgnoredSection() = (%d, %v, %v), want advanced unblocked CDATA", next, adv, blk)
}
if want := len("<![cDaTa[<tool_calls>]]>"); next != want {
t.Fatalf("skipXMLIgnoredSection() next = %d, want %d", next, want)
}
tag, ok := FindToolMarkupTagOutsideIgnored(text, 0)
if !ok {
t.Fatal("expected tool tag after skipped CDATA")
}
if tag.Start != next {
t.Fatalf("FindToolMarkupTagOutsideIgnored() start = %d, want %d", tag.Start, next)
}
}
func TestFindToolCDATAEndBoundaryConditions(t *testing.T) {
text := "<![CDATA[hello]]>"
tests := []struct {
name string
from int
wantResult int
}{
{"valid", 12, 14},
{"at_end", 17, -1},
{"beyond_end", 18, -1},
{"negative", -1, -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := findToolCDATAEnd(text, tt.from)
if got != tt.wantResult {
t.Errorf("findToolCDATAEnd(%q, %d) = %d, want %d",
text, tt.from, got, tt.wantResult)
}
})
}
}
func TestFindMatchingToolMarkupCloseBoundaryConditions(t *testing.T) {
tests := []struct {
name string
text string
open ToolMarkupTag
wantOk bool
}{
{"empty_text", "", ToolMarkupTag{Name: "tool_calls", End: 0}, false},
{"open_end_beyond_text", "hello", ToolMarkupTag{Name: "tool_calls", End: 100}, false},
{"open_end_equals_len", "hello", ToolMarkupTag{Name: "tool_calls", End: 5}, false},
{"valid_simple", "<tool_calls></tool_calls>", ToolMarkupTag{Name: "tool_calls", End: 11}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, ok := FindMatchingToolMarkupClose(tt.text, tt.open)
if ok != tt.wantOk {
t.Errorf("FindMatchingToolMarkupClose(%q, %+v) ok = %v, want %v", tt.text, tt.open, ok, tt.wantOk)
}
})
}
}

View File

@@ -758,9 +758,11 @@ test('shouldSkipPath skips dynamic response/fragments/*/status paths only', () =
assert.equal(shouldSkipPath('response/status'), false); assert.equal(shouldSkipPath('response/status'), false);
}); });
test('node stream path guard only allows /v1/chat/completions', () => { test('node stream path guard allows OpenAI v1 and root alias chat completions paths', () => {
assert.equal(isNodeStreamSupportedPath('/v1/chat/completions'), true); assert.equal(isNodeStreamSupportedPath('/v1/chat/completions'), true);
assert.equal(isNodeStreamSupportedPath('/v1/chat/completions?x=1'), true); assert.equal(isNodeStreamSupportedPath('/v1/chat/completions?x=1'), true);
assert.equal(isNodeStreamSupportedPath('/chat/completions'), true);
assert.equal(isNodeStreamSupportedPath('/chat/completions?x=1'), true);
assert.equal(isNodeStreamSupportedPath('/v1beta/models/gemini-2.5-flash:streamGenerateContent'), false); assert.equal(isNodeStreamSupportedPath('/v1beta/models/gemini-2.5-flash:streamGenerateContent'), false);
assert.equal(isNodeStreamSupportedPath('/anthropic/v1/messages'), false); assert.equal(isNodeStreamSupportedPath('/anthropic/v1/messages'), false);
}); });
@@ -768,6 +770,7 @@ test('node stream path guard only allows /v1/chat/completions', () => {
test('extractPathname strips query only', () => { test('extractPathname strips query only', () => {
assert.equal(extractPathname('/v1/chat/completions?stream=true'), '/v1/chat/completions'); assert.equal(extractPathname('/v1/chat/completions?stream=true'), '/v1/chat/completions');
assert.equal(extractPathname('/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=1'), '/v1beta/models/gemini-2.5-flash:streamGenerateContent'); assert.equal(extractPathname('/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=1'), '/v1beta/models/gemini-2.5-flash:streamGenerateContent');
assert.equal(extractPathname('/chat/completions?stream=true'), '/chat/completions');
}); });
test('setCorsHeaders reflects requested third-party headers and blocks internal-only headers', () => { test('setCorsHeaders reflects requested third-party headers and blocks internal-only headers', () => {