mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-10 19:27:41 +08:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbf2bfb64f | ||
|
|
9e9a7f1bec | ||
|
|
96691aa37a | ||
|
|
a3ce8008af | ||
|
|
23a79df687 | ||
|
|
e251a7ee29 | ||
|
|
30cca7cda0 | ||
|
|
ab163edee7 | ||
|
|
1201c3773f | ||
|
|
595ddf52af | ||
|
|
0adffccd46 | ||
|
|
0670d5acb4 | ||
|
|
239c4faa97 | ||
|
|
f33789399e | ||
|
|
1e00e482a6 | ||
|
|
7ab5a0e66d | ||
|
|
410efbd70b | ||
|
|
7179b995bb | ||
|
|
fef3798e5e | ||
|
|
00fe18b505 | ||
|
|
9b746e32d8 | ||
|
|
ace440481a | ||
|
|
66e0fa568f | ||
|
|
fa489248bc | ||
|
|
657b9379ed | ||
|
|
9062330104 | ||
|
|
d0d61a5d77 | ||
|
|
ffef451f7a | ||
|
|
a68a79e087 | ||
|
|
c8db66615c | ||
|
|
79ae9c8970 | ||
|
|
2378f0fbe7 | ||
|
|
aa29084038 | ||
|
|
21c1527c79 | ||
|
|
7ec0d99702 | ||
|
|
7e639667f8 | ||
|
|
066c48c107 | ||
|
|
d69b0658ea | ||
|
|
4315b424bf | ||
|
|
4678a061d0 | ||
|
|
70076c217f | ||
|
|
554fae6b3f | ||
|
|
76884c0d94 | ||
|
|
269d7cd8f9 | ||
|
|
7870a61bb0 | ||
|
|
ec4f178908 |
38
API.en.md
38
API.en.md
@@ -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`
|
||||||
@@ -660,11 +680,13 @@ Requires JWT: `Authorization: Bearer <jwt>`
|
|||||||
|
|
||||||
### `GET /admin/vercel/config`
|
### `GET /admin/vercel/config`
|
||||||
|
|
||||||
Returns Vercel preconfiguration status.
|
Returns Vercel preconfiguration status. Environment variables are preferred, then the saved `vercel` config block is used as a fallback.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"has_token": true,
|
"has_token": true,
|
||||||
|
"token_preview": "vc****en",
|
||||||
|
"token_source": "config",
|
||||||
"project_id": "prj_xxx",
|
"project_id": "prj_xxx",
|
||||||
"team_id": null
|
"team_id": null
|
||||||
}
|
}
|
||||||
@@ -685,6 +707,12 @@ Returns sanitized config, including both `keys` and `api_keys`.
|
|||||||
"env_source_present": true,
|
"env_source_present": true,
|
||||||
"env_writeback_enabled": true,
|
"env_writeback_enabled": true,
|
||||||
"config_path": "/data/config.json",
|
"config_path": "/data/config.json",
|
||||||
|
"vercel": {
|
||||||
|
"has_token": true,
|
||||||
|
"token_preview": "vc****en",
|
||||||
|
"project_id": "prj_xxx",
|
||||||
|
"team_id": ""
|
||||||
|
},
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
"identifier": "user@example.com",
|
"identifier": "user@example.com",
|
||||||
@@ -1096,11 +1124,11 @@ The success payload includes `sample_id`, `dir`, `meta_path`, and `upstream_path
|
|||||||
|
|
||||||
| Field | Required | Notes |
|
| Field | Required | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `vercel_token` | ❌ | If empty or `__USE_PRECONFIG__`, read env |
|
| `vercel_token` | ❌ | If empty or `__USE_PRECONFIG__`, read env, then saved config |
|
||||||
| `project_id` | ❌ | Fallback: `VERCEL_PROJECT_ID` |
|
| `project_id` | ❌ | Fallback: `VERCEL_PROJECT_ID`, then saved config |
|
||||||
| `team_id` | ❌ | Fallback: `VERCEL_TEAM_ID` |
|
| `team_id` | ❌ | Fallback: `VERCEL_TEAM_ID`, then saved config |
|
||||||
| `auto_validate` | ❌ | Default `true` |
|
| `auto_validate` | ❌ | Default `true` |
|
||||||
| `save_credentials` | ❌ | Default `true` |
|
| `save_credentials` | ❌ | Default `true`; saves explicitly supplied Vercel credentials for the next sync |
|
||||||
|
|
||||||
**Success response**:
|
**Success response**:
|
||||||
|
|
||||||
|
|||||||
38
API.md
38
API.md
@@ -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`
|
||||||
@@ -671,11 +691,13 @@ data: {"type":"message_stop"}
|
|||||||
|
|
||||||
### `GET /admin/vercel/config`
|
### `GET /admin/vercel/config`
|
||||||
|
|
||||||
返回 Vercel 预配置状态。
|
返回 Vercel 预配置状态。优先读取环境变量,其次回退到已保存的 `vercel` 配置块。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"has_token": true,
|
"has_token": true,
|
||||||
|
"token_preview": "vc****en",
|
||||||
|
"token_source": "config",
|
||||||
"project_id": "prj_xxx",
|
"project_id": "prj_xxx",
|
||||||
"team_id": null
|
"team_id": null
|
||||||
}
|
}
|
||||||
@@ -696,6 +718,12 @@ data: {"type":"message_stop"}
|
|||||||
"env_source_present": true,
|
"env_source_present": true,
|
||||||
"env_writeback_enabled": true,
|
"env_writeback_enabled": true,
|
||||||
"config_path": "/data/config.json",
|
"config_path": "/data/config.json",
|
||||||
|
"vercel": {
|
||||||
|
"has_token": true,
|
||||||
|
"token_preview": "vc****en",
|
||||||
|
"project_id": "prj_xxx",
|
||||||
|
"team_id": ""
|
||||||
|
},
|
||||||
"accounts": [
|
"accounts": [
|
||||||
{
|
{
|
||||||
"identifier": "user@example.com",
|
"identifier": "user@example.com",
|
||||||
@@ -1109,11 +1137,11 @@ data: {"type":"message_stop"}
|
|||||||
|
|
||||||
| 字段 | 必填 | 说明 |
|
| 字段 | 必填 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `vercel_token` | ❌ | 空或 `__USE_PRECONFIG__` 则读环境变量 |
|
| `vercel_token` | ❌ | 空或 `__USE_PRECONFIG__` 则读环境变量,再回退到已保存配置 |
|
||||||
| `project_id` | ❌ | 空则读 `VERCEL_PROJECT_ID` |
|
| `project_id` | ❌ | 空则读 `VERCEL_PROJECT_ID`,再回退到已保存配置 |
|
||||||
| `team_id` | ❌ | 空则读 `VERCEL_TEAM_ID` |
|
| `team_id` | ❌ | 空则读 `VERCEL_TEAM_ID`,再回退到已保存配置 |
|
||||||
| `auto_validate` | ❌ | 默认 `true` |
|
| `auto_validate` | ❌ | 默认 `true` |
|
||||||
| `save_credentials` | ❌ | 默认 `true` |
|
| `save_credentials` | ❌ | 默认 `true`;保存本次显式填写的 Vercel 凭据,供下次同步复用 |
|
||||||
|
|
||||||
**成功响应**:
|
**成功响应**:
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
|
|||||||
if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
|
if c.ThinkingInjection.Enabled != nil || strings.TrimSpace(c.ThinkingInjection.Prompt) != "" {
|
||||||
m["thinking_injection"] = c.ThinkingInjection
|
m["thinking_injection"] = c.ThinkingInjection
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(c.Vercel.Token) != "" || strings.TrimSpace(c.Vercel.ProjectID) != "" || strings.TrimSpace(c.Vercel.TeamID) != "" {
|
||||||
|
m["vercel"] = NormalizeVercelConfig(c.Vercel)
|
||||||
|
}
|
||||||
if c.VercelSyncHash != "" {
|
if c.VercelSyncHash != "" {
|
||||||
m["_vercel_sync_hash"] = c.VercelSyncHash
|
m["_vercel_sync_hash"] = c.VercelSyncHash
|
||||||
}
|
}
|
||||||
@@ -125,6 +128,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
|
|||||||
if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil {
|
if err := json.Unmarshal(v, &c.ThinkingInjection); err != nil {
|
||||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||||
}
|
}
|
||||||
|
case "vercel":
|
||||||
|
if err := json.Unmarshal(v, &c.Vercel); err != nil {
|
||||||
|
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||||
|
}
|
||||||
case "_vercel_sync_hash":
|
case "_vercel_sync_hash":
|
||||||
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
|
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
|
||||||
return fmt.Errorf("invalid field %q: %w", k, err)
|
return fmt.Errorf("invalid field %q: %w", k, err)
|
||||||
@@ -164,6 +171,7 @@ func (c Config) Clone() Config {
|
|||||||
Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
|
Enabled: cloneBoolPtr(c.ThinkingInjection.Enabled),
|
||||||
Prompt: c.ThinkingInjection.Prompt,
|
Prompt: c.ThinkingInjection.Prompt,
|
||||||
},
|
},
|
||||||
|
Vercel: c.Vercel,
|
||||||
VercelSyncHash: c.VercelSyncHash,
|
VercelSyncHash: c.VercelSyncHash,
|
||||||
VercelSyncTime: c.VercelSyncTime,
|
VercelSyncTime: c.VercelSyncTime,
|
||||||
AdditionalFields: map[string]any{},
|
AdditionalFields: map[string]any{},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Config struct {
|
|||||||
AutoDelete AutoDeleteConfig `json:"auto_delete"`
|
AutoDelete AutoDeleteConfig `json:"auto_delete"`
|
||||||
CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
|
CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
|
||||||
ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
|
ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
|
||||||
|
Vercel VercelConfig `json:"vercel,omitempty"`
|
||||||
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
|
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
|
||||||
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
|
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
|
||||||
AdditionalFields map[string]any `json:"-"`
|
AdditionalFields map[string]any `json:"-"`
|
||||||
@@ -99,6 +100,7 @@ func (c *Config) NormalizeCredentials() {
|
|||||||
c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark)
|
c.Accounts[i].Remark = strings.TrimSpace(c.Accounts[i].Remark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Vercel = NormalizeVercelConfig(c.Vercel)
|
||||||
c.normalizeModelAliases()
|
c.normalizeModelAliases()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,3 +177,24 @@ type ThinkingInjectionConfig struct {
|
|||||||
Enabled *bool `json:"enabled,omitempty"`
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
Prompt string `json:"prompt,omitempty"`
|
Prompt string `json:"prompt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VercelConfig struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
ProjectID string `json:"project_id,omitempty"`
|
||||||
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeVercelConfig(v VercelConfig) VercelConfig {
|
||||||
|
return VercelConfig{
|
||||||
|
Token: strings.TrimSpace(v.Token),
|
||||||
|
ProjectID: strings.TrimSpace(v.ProjectID),
|
||||||
|
TeamID: strings.TrimSpace(v.TeamID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) ClearVercelCredentials() {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Vercel = VercelConfig{}
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,6 +173,11 @@ func TestConfigJSONRoundtrip(t *testing.T) {
|
|||||||
Runtime: RuntimeConfig{
|
Runtime: RuntimeConfig{
|
||||||
TokenRefreshIntervalHours: 12,
|
TokenRefreshIntervalHours: 12,
|
||||||
},
|
},
|
||||||
|
Vercel: VercelConfig{
|
||||||
|
Token: " vercel-token ",
|
||||||
|
ProjectID: " prj_123 ",
|
||||||
|
TeamID: " team_123 ",
|
||||||
|
},
|
||||||
VercelSyncHash: "hash123",
|
VercelSyncHash: "hash123",
|
||||||
VercelSyncTime: 1234567890,
|
VercelSyncTime: 1234567890,
|
||||||
AdditionalFields: map[string]any{
|
AdditionalFields: map[string]any{
|
||||||
@@ -205,6 +210,9 @@ func TestConfigJSONRoundtrip(t *testing.T) {
|
|||||||
if decoded.AutoDelete.Mode != "single" {
|
if decoded.AutoDelete.Mode != "single" {
|
||||||
t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode)
|
t.Fatalf("unexpected auto delete mode: %#v", decoded.AutoDelete.Mode)
|
||||||
}
|
}
|
||||||
|
if decoded.Vercel.Token != "vercel-token" || decoded.Vercel.ProjectID != "prj_123" || decoded.Vercel.TeamID != "team_123" {
|
||||||
|
t.Fatalf("unexpected vercel config: %#v", decoded.Vercel)
|
||||||
|
}
|
||||||
if decoded.VercelSyncHash != "hash123" {
|
if decoded.VercelSyncHash != "hash123" {
|
||||||
t.Fatalf("unexpected vercel sync hash: %q", decoded.VercelSyncHash)
|
t.Fatalf("unexpected vercel sync hash: %q", decoded.VercelSyncHash)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ type Handler struct {
|
|||||||
|
|
||||||
var writeJSON = adminshared.WriteJSON
|
var writeJSON = adminshared.WriteJSON
|
||||||
var intFrom = adminshared.IntFrom
|
var intFrom = adminshared.IntFrom
|
||||||
|
var maskSecretPreview = adminshared.MaskSecretPreview
|
||||||
|
|
||||||
func nilIfEmpty(s string) any { return adminshared.NilIfEmpty(s) }
|
func nilIfEmpty(s string) any { return adminshared.NilIfEmpty(s) }
|
||||||
|
|||||||
@@ -61,9 +61,34 @@ func (h *Handler) verify(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) getVercelConfig(w http.ResponseWriter, _ *http.Request) {
|
func (h *Handler) getVercelConfig(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
saved := h.Store.Snapshot().Vercel
|
||||||
|
token, tokenSource := firstConfiguredValue(
|
||||||
|
[2]string{"env", os.Getenv("VERCEL_TOKEN")},
|
||||||
|
[2]string{"config", saved.Token},
|
||||||
|
)
|
||||||
|
projectID, _ := firstConfiguredValue(
|
||||||
|
[2]string{"env", os.Getenv("VERCEL_PROJECT_ID")},
|
||||||
|
[2]string{"config", saved.ProjectID},
|
||||||
|
)
|
||||||
|
teamID, _ := firstConfiguredValue(
|
||||||
|
[2]string{"env", os.Getenv("VERCEL_TEAM_ID")},
|
||||||
|
[2]string{"config", saved.TeamID},
|
||||||
|
)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"has_token": strings.TrimSpace(os.Getenv("VERCEL_TOKEN")) != "",
|
"has_token": token != "",
|
||||||
"project_id": strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID")),
|
"token_preview": maskSecretPreview(token),
|
||||||
"team_id": nilIfEmpty(strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))),
|
"token_source": nilIfEmpty(tokenSource),
|
||||||
|
"project_id": projectID,
|
||||||
|
"team_id": nilIfEmpty(teamID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstConfiguredValue(values ...[2]string) (string, string) {
|
||||||
|
for _, pair := range values {
|
||||||
|
value := strings.TrimSpace(pair[1])
|
||||||
|
if value != "" {
|
||||||
|
return value, strings.TrimSpace(pair[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|||||||
38
internal/httpapi/admin/auth/handler_auth_test.go
Normal file
38
internal/httpapi/admin/auth/handler_auth_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ds2api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetVercelConfigFallsBackToSavedConfig(t *testing.T) {
|
||||||
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"vercel":{"token":"saved-token","project_id":"saved-project","team_id":"saved-team"}}`)
|
||||||
|
t.Setenv("VERCEL_TOKEN", "")
|
||||||
|
t.Setenv("VERCEL_PROJECT_ID", "")
|
||||||
|
t.Setenv("VERCEL_TEAM_ID", "")
|
||||||
|
h := &Handler{Store: config.LoadStore()}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.getVercelConfig(rec, httptest.NewRequest(http.MethodGet, "/admin/vercel/config", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if payload["has_token"] != true {
|
||||||
|
t.Fatalf("expected saved token to be detected: %#v", payload)
|
||||||
|
}
|
||||||
|
if payload["token_source"] != "config" || payload["project_id"] != "saved-project" || payload["team_id"] != "saved-team" {
|
||||||
|
t.Fatalf("unexpected preconfig payload: %#v", payload)
|
||||||
|
}
|
||||||
|
if payload["token_preview"] == "saved-token" {
|
||||||
|
t.Fatal("token preview leaked the full token")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,10 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
if strings.TrimSpace(incoming.Embeddings.Provider) != "" {
|
if strings.TrimSpace(incoming.Embeddings.Provider) != "" {
|
||||||
next.Embeddings.Provider = incoming.Embeddings.Provider
|
next.Embeddings.Provider = incoming.Embeddings.Provider
|
||||||
}
|
}
|
||||||
|
incomingVercel := config.NormalizeVercelConfig(incoming.Vercel)
|
||||||
|
if strings.TrimSpace(incomingVercel.Token) != "" || strings.TrimSpace(incomingVercel.ProjectID) != "" || strings.TrimSpace(incomingVercel.TeamID) != "" {
|
||||||
|
next.Vercel = incomingVercel
|
||||||
|
}
|
||||||
if strings.TrimSpace(incoming.Admin.PasswordHash) != "" {
|
if strings.TrimSpace(incoming.Admin.PasswordHash) != "" {
|
||||||
next.Admin.PasswordHash = incoming.Admin.PasswordHash
|
next.Admin.PasswordHash = incoming.Admin.PasswordHash
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
|||||||
"env_writeback_enabled": h.Store.IsEnvWritebackEnabled(),
|
"env_writeback_enabled": h.Store.IsEnvWritebackEnabled(),
|
||||||
"config_path": h.Store.ConfigPath(),
|
"config_path": h.Store.ConfigPath(),
|
||||||
"model_aliases": snap.ModelAliases,
|
"model_aliases": snap.ModelAliases,
|
||||||
|
"vercel": map[string]any{
|
||||||
|
"has_token": strings.TrimSpace(snap.Vercel.Token) != "",
|
||||||
|
"token_preview": maskSecretPreview(snap.Vercel.Token),
|
||||||
|
"project_id": snap.Vercel.ProjectID,
|
||||||
|
"team_id": snap.Vercel.TeamID,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
accounts := make([]map[string]any, 0, len(snap.Accounts))
|
accounts := make([]map[string]any, 0, len(snap.Accounts))
|
||||||
for _, acc := range snap.Accounts {
|
for _, acc := range snap.Accounts {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ func ComputeSyncHash(store ConfigStore) string {
|
|||||||
}
|
}
|
||||||
snap := store.Snapshot().Clone()
|
snap := store.Snapshot().Clone()
|
||||||
snap.ClearAccountTokens()
|
snap.ClearAccountTokens()
|
||||||
|
snap.ClearVercelCredentials()
|
||||||
snap.VercelSyncHash = ""
|
snap.VercelSyncHash = ""
|
||||||
snap.VercelSyncTime = 0
|
snap.VercelSyncTime = 0
|
||||||
b, _ := json.Marshal(snap)
|
b, _ := json.Marshal(snap)
|
||||||
@@ -93,6 +94,7 @@ func SyncHashForJSON(s string) string {
|
|||||||
cfg.VercelSyncHash = ""
|
cfg.VercelSyncHash = ""
|
||||||
cfg.VercelSyncTime = 0
|
cfg.VercelSyncTime = 0
|
||||||
cfg.ClearAccountTokens()
|
cfg.ClearAccountTokens()
|
||||||
|
cfg.ClearVercelCredentials()
|
||||||
b, err := json.Marshal(cfg)
|
b, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
opts, err := parseVercelSyncOptions(req)
|
opts, err := parseVercelSyncOptions(req, h.Store.Snapshot().Vercel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -50,6 +50,12 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
|
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
|
||||||
|
credentialsWarning := ""
|
||||||
|
if saved, err := h.saveLocalVercelCredentials(opts); err == nil && saved {
|
||||||
|
savedCreds = append(savedCreds, "config.vercel")
|
||||||
|
} else if err != nil {
|
||||||
|
credentialsWarning = "保存 Vercel 凭据到本地配置失败: " + err.Error()
|
||||||
|
}
|
||||||
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
|
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
|
||||||
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
|
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
|
||||||
result := map[string]any{"success": true, "validated_accounts": validated}
|
result := map[string]any{"success": true, "validated_accounts": validated}
|
||||||
@@ -66,6 +72,9 @@ func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(savedCreds) > 0 {
|
if len(savedCreds) > 0 {
|
||||||
result["saved_credentials"] = savedCreds
|
result["saved_credentials"] = savedCreds
|
||||||
}
|
}
|
||||||
|
if credentialsWarning != "" {
|
||||||
|
result["credentials_warning"] = credentialsWarning
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, result)
|
writeJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +87,7 @@ type vercelSyncOptions struct {
|
|||||||
UsePreconfig bool
|
UsePreconfig bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) {
|
func parseVercelSyncOptions(req map[string]any, saved config.VercelConfig) (vercelSyncOptions, error) {
|
||||||
vercelToken, _ := req["vercel_token"].(string)
|
vercelToken, _ := req["vercel_token"].(string)
|
||||||
projectID, _ := req["project_id"].(string)
|
projectID, _ := req["project_id"].(string)
|
||||||
teamID, _ := req["team_id"].(string)
|
teamID, _ := req["team_id"].(string)
|
||||||
@@ -92,13 +101,13 @@ func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) {
|
|||||||
}
|
}
|
||||||
usePreconfig := vercelToken == "__USE_PRECONFIG__" || strings.TrimSpace(vercelToken) == ""
|
usePreconfig := vercelToken == "__USE_PRECONFIG__" || strings.TrimSpace(vercelToken) == ""
|
||||||
if usePreconfig {
|
if usePreconfig {
|
||||||
vercelToken = strings.TrimSpace(os.Getenv("VERCEL_TOKEN"))
|
vercelToken = firstNonEmpty(os.Getenv("VERCEL_TOKEN"), saved.Token)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(projectID) == "" {
|
if strings.TrimSpace(projectID) == "" {
|
||||||
projectID = strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID"))
|
projectID = firstNonEmpty(os.Getenv("VERCEL_PROJECT_ID"), saved.ProjectID)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(teamID) == "" {
|
if strings.TrimSpace(teamID) == "" {
|
||||||
teamID = strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))
|
teamID = firstNonEmpty(os.Getenv("VERCEL_TEAM_ID"), saved.TeamID)
|
||||||
}
|
}
|
||||||
vercelToken = strings.TrimSpace(vercelToken)
|
vercelToken = strings.TrimSpace(vercelToken)
|
||||||
projectID = strings.TrimSpace(projectID)
|
projectID = strings.TrimSpace(projectID)
|
||||||
@@ -116,6 +125,15 @@ func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func buildVercelParams(teamID string) url.Values {
|
func buildVercelParams(teamID string) url.Values {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
if strings.TrimSpace(teamID) != "" {
|
if strings.TrimSpace(teamID) != "" {
|
||||||
@@ -178,6 +196,25 @@ func (h *Handler) saveVercelProjectCredentials(ctx context.Context, client *http
|
|||||||
return saved
|
return saved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) saveLocalVercelCredentials(opts vercelSyncOptions) (bool, error) {
|
||||||
|
if !opts.SaveCreds {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
err := h.Store.Update(func(c *config.Config) error {
|
||||||
|
token := opts.VercelToken
|
||||||
|
if opts.UsePreconfig {
|
||||||
|
token = c.Vercel.Token
|
||||||
|
}
|
||||||
|
c.Vercel = config.NormalizeVercelConfig(config.VercelConfig{
|
||||||
|
Token: token,
|
||||||
|
ProjectID: opts.ProjectID,
|
||||||
|
TeamID: opts.TeamID,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID string, params url.Values, headers map[string]string) (bool, string) {
|
func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID string, params url.Values, headers map[string]string) (bool, string) {
|
||||||
projectResp, status, _ := vercelRequest(ctx, client, http.MethodGet, "https://api.vercel.com/v9/projects/"+projectID, params, headers, nil)
|
projectResp, status, _ := vercelRequest(ctx, client, http.MethodGet, "https://api.vercel.com/v9/projects/"+projectID, params, headers, nil)
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
@@ -243,7 +280,7 @@ func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
|
func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
|
||||||
override, ok := req["config_override"]
|
override, ok := req["config_override"]
|
||||||
if !ok || override == nil {
|
if !ok || override == nil {
|
||||||
return h.Store.ExportJSONAndBase64()
|
return encodeVercelSyncConfig(h.Store.Snapshot())
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(override)
|
raw, err := json.Marshal(override)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -253,8 +290,13 @@ func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
|
|||||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
return encodeVercelSyncConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeVercelSyncConfig(cfg config.Config) (string, string, error) {
|
||||||
cfg.DropInvalidAccounts()
|
cfg.DropInvalidAccounts()
|
||||||
cfg.ClearAccountTokens()
|
cfg.ClearAccountTokens()
|
||||||
|
cfg.ClearVercelCredentials()
|
||||||
cfg.VercelSyncHash = ""
|
cfg.VercelSyncHash = ""
|
||||||
cfg.VercelSyncTime = 0
|
cfg.VercelSyncTime = 0
|
||||||
b, err := json.Marshal(cfg)
|
b, err := json.Marshal(cfg)
|
||||||
@@ -272,6 +314,7 @@ func syncHashForJSON(s string) string {
|
|||||||
cfg.VercelSyncHash = ""
|
cfg.VercelSyncHash = ""
|
||||||
cfg.VercelSyncTime = 0
|
cfg.VercelSyncTime = 0
|
||||||
cfg.ClearAccountTokens()
|
cfg.ClearAccountTokens()
|
||||||
|
cfg.ClearVercelCredentials()
|
||||||
b, err := json.Marshal(cfg)
|
b, err := json.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
100
internal/httpapi/admin/vercel/handler_vercel_test.go
Normal file
100
internal/httpapi/admin/vercel/handler_vercel_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package vercel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"ds2api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseVercelSyncOptionsFallsBackToSavedConfig(t *testing.T) {
|
||||||
|
t.Setenv("VERCEL_TOKEN", "")
|
||||||
|
t.Setenv("VERCEL_PROJECT_ID", "")
|
||||||
|
t.Setenv("VERCEL_TEAM_ID", "")
|
||||||
|
|
||||||
|
opts, err := parseVercelSyncOptions(map[string]any{
|
||||||
|
"vercel_token": "__USE_PRECONFIG__",
|
||||||
|
}, config.VercelConfig{
|
||||||
|
Token: " saved-token ",
|
||||||
|
ProjectID: " saved-project ",
|
||||||
|
TeamID: " saved-team ",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse options error: %v", err)
|
||||||
|
}
|
||||||
|
if opts.VercelToken != "saved-token" || opts.ProjectID != "saved-project" || opts.TeamID != "saved-team" {
|
||||||
|
t.Fatalf("unexpected options: %#v", opts)
|
||||||
|
}
|
||||||
|
if !opts.UsePreconfig {
|
||||||
|
t.Fatal("expected preconfig mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveLocalVercelCredentialsStoresExplicitInput(t *testing.T) {
|
||||||
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"]}`)
|
||||||
|
store := config.LoadStore()
|
||||||
|
h := &Handler{Store: store}
|
||||||
|
|
||||||
|
saved, err := h.saveLocalVercelCredentials(vercelSyncOptions{
|
||||||
|
VercelToken: " token ",
|
||||||
|
ProjectID: " project ",
|
||||||
|
TeamID: " team ",
|
||||||
|
SaveCreds: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save local credentials error: %v", err)
|
||||||
|
}
|
||||||
|
if !saved {
|
||||||
|
t.Fatal("expected credentials to be saved")
|
||||||
|
}
|
||||||
|
got := store.Snapshot().Vercel
|
||||||
|
if got.Token != "token" || got.ProjectID != "project" || got.TeamID != "team" {
|
||||||
|
t.Fatalf("unexpected saved credentials: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveLocalVercelCredentialsPreservesPreconfiguredTokenAndUpdatesProject(t *testing.T) {
|
||||||
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"vercel":{"token":"saved-token","project_id":"old-project","team_id":"old-team"}}`)
|
||||||
|
store := config.LoadStore()
|
||||||
|
h := &Handler{Store: store}
|
||||||
|
|
||||||
|
saved, err := h.saveLocalVercelCredentials(vercelSyncOptions{
|
||||||
|
VercelToken: "resolved-token",
|
||||||
|
ProjectID: "new-project",
|
||||||
|
TeamID: "new-team",
|
||||||
|
SaveCreds: true,
|
||||||
|
UsePreconfig: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save local credentials error: %v", err)
|
||||||
|
}
|
||||||
|
if !saved {
|
||||||
|
t.Fatal("expected project/team updates to be saved")
|
||||||
|
}
|
||||||
|
got := store.Snapshot().Vercel
|
||||||
|
if got.Token != "saved-token" || got.ProjectID != "new-project" || got.TeamID != "new-team" {
|
||||||
|
t.Fatalf("unexpected saved credentials: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportSyncConfigStripsSavedVercelCredentials(t *testing.T) {
|
||||||
|
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"vercel":{"token":"secret-token","project_id":"project","team_id":"team"}}`)
|
||||||
|
store := config.LoadStore()
|
||||||
|
h := &Handler{Store: store}
|
||||||
|
|
||||||
|
jsonStr, _, err := h.exportSyncConfig(map[string]any{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("export sync config error: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(jsonStr, "secret-token") || strings.Contains(jsonStr, `"vercel"`) {
|
||||||
|
t.Fatalf("expected sync export to strip Vercel credentials, got %s", jsonStr)
|
||||||
|
}
|
||||||
|
var exported config.Config
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &exported); err != nil {
|
||||||
|
t.Fatalf("exported config is invalid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if len(exported.Keys) != 1 || exported.Keys[0] != "k1" {
|
||||||
|
t.Fatalf("unexpected exported config: %#v", exported)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "<|DSML|tool_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") {
|
||||||
|
|||||||
58
internal/httpapi/ollama/handler_routes.go
Normal file
58
internal/httpapi/ollama/handler_routes.go
Normal 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)
|
||||||
|
}
|
||||||
127
internal/httpapi/ollama/handler_routes_test.go
Normal file
127
internal/httpapi/ollama/handler_routes_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 <|DSML|tool_calls>...</|DSML|tool_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") {
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
<|DSML|tool_calls>
|
||||||
<|DSML|invoke name="TOOL_NAME_HERE">
|
<|DSML|invoke name="TOOL_NAME_HERE">
|
||||||
<|DSML|parameter name="PARAMETER_NAME"><![CDATA[PARAMETER_VALUE]]></|DSML|parameter>
|
<|DSML|parameter name="PARAMETER_NAME"><![CDATA[PARAMETER_VALUE]]></|DSML|parameter>
|
||||||
</|DSML|invoke>
|
</|DSML|invoke>
|
||||||
</|DSML|tool_calls>
|
</|DSML|tool_calls>
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
1) Use the <|DSML|tool_calls> wrapper format.
|
1) Use the <|DSML|tool_calls> wrapper format.
|
||||||
2) Put one or more <|DSML|invoke> entries under a single <|DSML|tool_calls> root.
|
2) Put one or more <|DSML|invoke> entries under a single <|DSML|tool_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: <|DSML|invoke 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 <|DSML|parameter name="ARG_NAME">...</|DSML|parameter> 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 <|DSML|tool_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 <|DSML|tool_calls> tag, even if you already plan to close with </|DSML|tool_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 => <|DSML|parameter name="x"><![CDATA[value]]></|DSML|parameter>
|
||||||
- object => <|DSML|parameter name="x"><field>...</field></|DSML|parameter>
|
- object => <|DSML|parameter name="x"><field>...</field></|DSML|parameter>
|
||||||
- array => <|DSML|parameter name="x"><item>...</item><item>...</item></|DSML|parameter>
|
- array => <|DSML|parameter name="x"><item>...</item><item>...</item></|DSML|parameter>
|
||||||
- number/bool/null => <|DSML|parameter name="x">plain_text</|DSML|parameter>
|
- number/bool/null => <|DSML|parameter name="x">plain_text</|DSML|parameter>
|
||||||
|
|
||||||
【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.
|
<|DSML|tool_calls>...</|DSML|tool_calls> I hope this helps.
|
||||||
Wrong 2 — Markdown code fences:
|
Wrong 2 — Markdown code fences:
|
||||||
` + "```xml" + `
|
` + "```xml" + `
|
||||||
<|DSML|tool_calls>...</|DSML|tool_calls>
|
<|DSML|tool_calls>...</|DSML|tool_calls>
|
||||||
` + "```" + `
|
` + "```" + `
|
||||||
Wrong 3 — missing opening wrapper:
|
Wrong 3 — missing opening wrapper:
|
||||||
<|DSML|invoke name="TOOL_NAME">...</|DSML|invoke>
|
<|DSML|invoke name="TOOL_NAME">...</|DSML|invoke>
|
||||||
</|DSML|tool_calls>
|
</|DSML|tool_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 <|DSML|tool_calls>...</|DSML|tool_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("<|DSML|tool_calls>\n")
|
||||||
for _, call := range calls {
|
for _, call := range calls {
|
||||||
b.WriteString(` <|DSML|invoke name="`)
|
b.WriteString(` <|DSML|invoke 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 </|DSML|invoke>\n")
|
||||||
}
|
}
|
||||||
b.WriteString("</|DSML|tool_calls>")
|
b.WriteString("</|DSML|tool_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 + `<|DSML|parameter name="content"></|DSML|parameter>`
|
||||||
}
|
}
|
||||||
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 `<|DSML|parameter name="` + name + `">` + inner + `</|DSML|parameter>`
|
||||||
}
|
}
|
||||||
|
|
||||||
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" + `<|DSML|parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></|DSML|parameter>`, 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" + `<|DSML|parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></|DSML|parameter>`, 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" + `<|DSML|parameter name="follow_up"><item><text>` + promptCDATA("Option A") + `</text></item><item><text>` + promptCDATA("Option B") + `</text></item></|DSML|parameter>`, true
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, `<|DSML|invoke 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, `<|DSML|parameter name="cmd"><![CDATA[pwd]]></|DSML|parameter>`) {
|
||||||
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, `<|DSML|invoke 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, `<|DSML|parameter name="command"><![CDATA[pwd]]></|DSML|parameter>`) {
|
||||||
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, `<|DSML|parameter 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, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter 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, `<|DSML|parameter 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, `<|DSML|invoke 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, `<|DSML|parameter 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, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter 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, `<|DSML|parameter 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, `<|DSML|parameter name="command">`) || strings.Contains(block, `<|DSML|parameter name="path">`) || strings.Contains(block, `<|DSML|parameter 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, `<|DSML|parameter name="file_path">`) || !strings.Contains(block, `<|DSML|parameter 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, `<|DSML|parameter 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 <|DSML|tool_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 := `<|DSML|invoke 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, `</|DSML|invoke>`)
|
||||||
if end < 0 {
|
if end < 0 {
|
||||||
return blocks
|
return blocks
|
||||||
}
|
}
|
||||||
end += len(`</|DSML|invoke>`)
|
end += len(`</|DSML|invoke>`)
|
||||||
blocks = append(blocks, remaining[:end])
|
blocks = append(blocks, remaining[:end])
|
||||||
remaining = remaining[end:]
|
remaining = remaining[end:]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,45 @@ func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "WebUI not built. Run `cd webui && npm run build` first.", http.StatusNotFound)
|
http.Error(w, "WebUI not built. Run `cd webui && npm run build` first.", http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// staticContentTypes pins the Content-Type of common WebUI assets so we do not
|
||||||
|
// rely on mime.TypeByExtension, which on Windows consults the registry and can
|
||||||
|
// return the wrong type (e.g. application/xml for .css) when third-party
|
||||||
|
// software has overwritten HKEY_CLASSES_ROOT entries. Browsers strictly enforce
|
||||||
|
// stylesheet/script MIME types and will refuse to apply a misidentified asset,
|
||||||
|
// breaking the /admin page on affected machines.
|
||||||
|
var staticContentTypes = map[string]string{
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".htm": "text/html; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".map": "application/json; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".otf": "font/otf",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
".wasm": "application/wasm",
|
||||||
|
}
|
||||||
|
|
||||||
|
// setStaticContentType pins the response Content-Type by file extension so that
|
||||||
|
// http.ServeFile does not fall back to mime.TypeByExtension (which on Windows
|
||||||
|
// reads the registry and may return an incorrect type).
|
||||||
|
func setStaticContentType(w http.ResponseWriter, fullPath string) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||||
|
if ct, ok := staticContentTypes[ext]; ok {
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) {
|
func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDir string) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
||||||
path = strings.TrimPrefix(path, "/")
|
path = strings.TrimPrefix(path, "/")
|
||||||
@@ -70,6 +109,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi
|
|||||||
} else {
|
} else {
|
||||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||||
}
|
}
|
||||||
|
setStaticContentType(w, full)
|
||||||
http.ServeFile(w, r, full)
|
http.ServeFile(w, r, full)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -82,6 +122,7 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||||
|
setStaticContentType(w, index)
|
||||||
http.ServeFile(w, r, index)
|
http.ServeFile(w, r, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
102
internal/webui/handler_test.go
Normal file
102
internal/webui/handler_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestServeFromDiskPinsContentType ensures static admin assets are returned
|
||||||
|
// with an explicit, RFC-compliant Content-Type that does not depend on
|
||||||
|
// mime.TypeByExtension. On Windows mime.TypeByExtension consults the registry
|
||||||
|
// (HKEY_CLASSES_ROOT) which third-party software can corrupt — for example
|
||||||
|
// installing certain editors rewrites .css to application/xml — and Chrome
|
||||||
|
// then refuses to apply a stylesheet whose Content-Type is not text/css,
|
||||||
|
// breaking the /admin page entirely. Pinning the type by file extension makes
|
||||||
|
// the response deterministic across operating systems and machine state.
|
||||||
|
func TestServeFromDiskPinsContentType(t *testing.T) {
|
||||||
|
staticDir := t.TempDir()
|
||||||
|
assetsDir := filepath.Join(staticDir, "assets")
|
||||||
|
if err := os.MkdirAll(assetsDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir assets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := map[string]string{
|
||||||
|
"index.html": "<!doctype html><html></html>",
|
||||||
|
"assets/index.css": "body{}",
|
||||||
|
"assets/index.js": "console.log(1)",
|
||||||
|
"assets/icon.svg": `<svg xmlns="http://www.w3.org/2000/svg"></svg>`,
|
||||||
|
"assets/source.js.map": `{"version":3}`,
|
||||||
|
}
|
||||||
|
for rel, body := range files {
|
||||||
|
full := filepath.Join(staticDir, filepath.FromSlash(rel))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", rel, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", rel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handler{StaticDir: staticDir}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
urlPath string
|
||||||
|
wantPrefix string
|
||||||
|
wantCacheCtl string
|
||||||
|
}{
|
||||||
|
{"/admin/assets/index.css", "text/css", "public, max-age=31536000, immutable"},
|
||||||
|
{"/admin/assets/index.js", "text/javascript", "public, max-age=31536000, immutable"},
|
||||||
|
{"/admin/assets/icon.svg", "image/svg+xml", "public, max-age=31536000, immutable"},
|
||||||
|
{"/admin/assets/source.js.map", "application/json", "public, max-age=31536000, immutable"},
|
||||||
|
// "/admin/index.html" is intentionally omitted: http.ServeFile redirects
|
||||||
|
// requests for index.html to "./", matching Go's net/http behavior. The
|
||||||
|
// route the SPA actually lands on is "/admin/" below.
|
||||||
|
{"/admin/", "text/html", "no-store, must-revalidate"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.urlPath, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.urlPath, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.serveFromDisk(rec, req, staticDir)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
ct := rec.Header().Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(ct, tc.wantPrefix) {
|
||||||
|
t.Fatalf("Content-Type = %q, want prefix %q", ct, tc.wantPrefix)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != tc.wantCacheCtl {
|
||||||
|
t.Fatalf("Cache-Control = %q, want %q", got, tc.wantCacheCtl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetStaticContentTypeUnknownExtensionFallsThrough verifies that unknown
|
||||||
|
// extensions leave the Content-Type header unset, so http.ServeFile can apply
|
||||||
|
// its own detection (sniffing or mime.TypeByExtension) for cases the pinned
|
||||||
|
// table does not cover.
|
||||||
|
func TestSetStaticContentTypeUnknownExtensionFallsThrough(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
setStaticContentType(rec, "/tmp/data.unknownext")
|
||||||
|
if got := rec.Header().Get("Content-Type"); got != "" {
|
||||||
|
t.Fatalf("Content-Type = %q, want empty for unknown extension", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetStaticContentTypeIsCaseInsensitive guards against a regression where
|
||||||
|
// uppercase extensions (e.g. STYLE.CSS shipped from some build pipelines)
|
||||||
|
// would bypass the pinned table and fall back to the registry on Windows.
|
||||||
|
func TestSetStaticContentTypeIsCaseInsensitive(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
setStaticContentType(rec, "/tmp/STYLE.CSS")
|
||||||
|
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/css") {
|
||||||
|
t.Fatalf("Content-Type = %q, want text/css prefix", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f
|
|||||||
setProjectId,
|
setProjectId,
|
||||||
teamId,
|
teamId,
|
||||||
setTeamId,
|
setTeamId,
|
||||||
|
saveCredentials,
|
||||||
|
setSaveCredentials,
|
||||||
loading,
|
loading,
|
||||||
result,
|
result,
|
||||||
preconfig,
|
preconfig,
|
||||||
@@ -46,6 +48,8 @@ export default function VercelSyncContainer({ onMessage, authFetch, isVercel = f
|
|||||||
setProjectId={setProjectId}
|
setProjectId={setProjectId}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
setTeamId={setTeamId}
|
setTeamId={setTeamId}
|
||||||
|
saveCredentials={saveCredentials}
|
||||||
|
setSaveCredentials={setSaveCredentials}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSync={handleSync}
|
onSync={handleSync}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export default function VercelSyncForm({
|
|||||||
setProjectId,
|
setProjectId,
|
||||||
teamId,
|
teamId,
|
||||||
setTeamId,
|
setTeamId,
|
||||||
|
saveCredentials,
|
||||||
|
setSaveCredentials,
|
||||||
loading,
|
loading,
|
||||||
onSync,
|
onSync,
|
||||||
}) {
|
}) {
|
||||||
@@ -124,6 +126,19 @@ export default function VercelSyncForm({
|
|||||||
onChange={e => setTeamId(e.target.value)}
|
onChange={e => setTeamId(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-ring"
|
||||||
|
checked={saveCredentials}
|
||||||
|
onChange={e => setSaveCredentials(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block font-medium">{t('vercel.saveCredentials')}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{t('vercel.saveCredentialsHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
|
|||||||
const [vercelToken, setVercelToken] = useState('')
|
const [vercelToken, setVercelToken] = useState('')
|
||||||
const [projectId, setProjectId] = useState('')
|
const [projectId, setProjectId] = useState('')
|
||||||
const [teamId, setTeamId] = useState('')
|
const [teamId, setTeamId] = useState('')
|
||||||
|
const [saveCredentials, setSaveCredentials] = useState(true)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [result, setResult] = useState(null)
|
const [result, setResult] = useState(null)
|
||||||
const [preconfig, setPreconfig] = useState(null)
|
const [preconfig, setPreconfig] = useState(null)
|
||||||
@@ -117,6 +118,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
|
|||||||
vercel_token: tokenToUse,
|
vercel_token: tokenToUse,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
team_id: teamId || undefined,
|
team_id: teamId || undefined,
|
||||||
|
save_credentials: saveCredentials,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -133,7 +135,7 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, t, teamId, vercelToken])
|
}, [apiFetch, fetchSyncStatus, onMessage, preconfig?.has_token, projectId, saveCredentials, t, teamId, vercelToken])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
vercelToken,
|
vercelToken,
|
||||||
@@ -142,6 +144,8 @@ export function useVercelSyncState({ apiFetch, onMessage, t, isVercel = false })
|
|||||||
setProjectId,
|
setProjectId,
|
||||||
teamId,
|
teamId,
|
||||||
setTeamId,
|
setTeamId,
|
||||||
|
saveCredentials,
|
||||||
|
setSaveCredentials,
|
||||||
loading,
|
loading,
|
||||||
result,
|
result,
|
||||||
preconfig,
|
preconfig,
|
||||||
|
|||||||
@@ -462,6 +462,8 @@
|
|||||||
"projectIdHint": "Find it in Project Settings → General.",
|
"projectIdHint": "Find it in Project Settings → General.",
|
||||||
"teamIdLabel": "Team ID",
|
"teamIdLabel": "Team ID",
|
||||||
"optional": "optional",
|
"optional": "optional",
|
||||||
|
"saveCredentials": "Remember Vercel credentials",
|
||||||
|
"saveCredentialsHint": "Save the token, project ID, and team ID for the next sync.",
|
||||||
"syncing": "Syncing...",
|
"syncing": "Syncing...",
|
||||||
"syncRedeploy": "Sync & redeploy",
|
"syncRedeploy": "Sync & redeploy",
|
||||||
"redeployHint": "This triggers a Vercel redeploy and usually takes 30–60 seconds.",
|
"redeployHint": "This triggers a Vercel redeploy and usually takes 30–60 seconds.",
|
||||||
|
|||||||
@@ -462,6 +462,8 @@
|
|||||||
"projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到",
|
"projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到",
|
||||||
"teamIdLabel": "团队 ID",
|
"teamIdLabel": "团队 ID",
|
||||||
"optional": "可选",
|
"optional": "可选",
|
||||||
|
"saveCredentials": "记住 Vercel 凭据",
|
||||||
|
"saveCredentialsHint": "保存访问令牌、项目 ID 和团队 ID,供下次同步直接复用。",
|
||||||
"syncing": "正在同步...",
|
"syncing": "正在同步...",
|
||||||
"syncRedeploy": "同步并重新部署",
|
"syncRedeploy": "同步并重新部署",
|
||||||
"redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",
|
"redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",
|
||||||
|
|||||||
Reference in New Issue
Block a user