From cf477ba3c20612b4ed08d3df81a4418fcf772625 Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sun, 23 Aug 2026 14:41:58 -0300 Subject: [PATCH 01/14] feat(zenifra): add Qwen3.8 27B model --- .../zenifra/models/alibaba/qwen3.6-35b-a3b.toml | 14 -------------- providers/zenifra/models/qwen3.8-27b.toml | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 14 deletions(-) delete mode 100644 providers/zenifra/models/alibaba/qwen3.6-35b-a3b.toml create mode 100644 providers/zenifra/models/qwen3.8-27b.toml diff --git a/providers/zenifra/models/alibaba/qwen3.6-35b-a3b.toml b/providers/zenifra/models/alibaba/qwen3.6-35b-a3b.toml deleted file mode 100644 index 2b20d088f45..00000000000 --- a/providers/zenifra/models/alibaba/qwen3.6-35b-a3b.toml +++ /dev/null @@ -1,14 +0,0 @@ -base_model = "alibaba/qwen3.6-35b-a3b" -reasoning = true -reasoning_options = [] - -[modalities] -input = ["text"] -output = ["text"] - -[provider] -shape = "completions" - -[cost] -input = 0.19 -output = 0.48 diff --git a/providers/zenifra/models/qwen3.8-27b.toml b/providers/zenifra/models/qwen3.8-27b.toml new file mode 100644 index 00000000000..551108a817b --- /dev/null +++ b/providers/zenifra/models/qwen3.8-27b.toml @@ -0,0 +1,15 @@ +# Reasoning: reasoning_effort = low|medium|xhigh +base_model = "alibaba/qwen3.8-27b" +reasoning_options = [{ type = "effort", values = ["low", "medium", "xhigh"] }] + +[modalities] +input = ["text"] +output = ["text"] + +[provider] +shape = "completions" + +[cost] +input = 0.30 +output = 0.90 +cache_read = 0.05 From 9866c5746277a604b1b273a5bca9d11bf95236b3 Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sun, 23 Aug 2026 14:49:25 -0300 Subject: [PATCH 02/14] fix(zenifra): align Qwen3.8 capabilities --- providers/zenifra/models/qwen3.8-27b.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/zenifra/models/qwen3.8-27b.toml b/providers/zenifra/models/qwen3.8-27b.toml index 551108a817b..7ffeda84317 100644 --- a/providers/zenifra/models/qwen3.8-27b.toml +++ b/providers/zenifra/models/qwen3.8-27b.toml @@ -1,10 +1,10 @@ # Reasoning: reasoning_effort = low|medium|xhigh base_model = "alibaba/qwen3.8-27b" +attachment = false reasoning_options = [{ type = "effort", values = ["low", "medium", "xhigh"] }] [modalities] input = ["text"] -output = ["text"] [provider] shape = "completions" From f46ee5e17879f5487897f5a6ee488bd826d996f3 Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sat, 12 Sep 2026 11:42:35 -0300 Subject: [PATCH 03/14] feat(zenifra): add hosted model catalog --- .../models/deepseek-v4-flash-0731.toml | 18 +++++++++++++++ providers/zenifra/models/deepseek-v4-pro.toml | 18 +++++++++++++++ providers/zenifra/models/glm-5.2.toml | 18 +++++++++++++++ providers/zenifra/models/kimi-k2.5.toml | 18 +++++++++++++++ providers/zenifra/models/kimi-k2.7-code.toml | 17 ++++++++++++++ providers/zenifra/models/kimi-k3.toml | 17 ++++++++++++++ providers/zenifra/models/qwen3.7-max.toml | 15 +++++++++++++ providers/zenifra/models/qwen3.7-plus.toml | 22 +++++++++++++++++++ providers/zenifra/models/qwen3.8-27b.toml | 17 +++++++------- providers/zenifra/models/qwen3.8-flash.toml | 16 ++++++++++++++ providers/zenifra/models/qwen3.8-max.toml | 17 ++++++++++++++ 11 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 providers/zenifra/models/deepseek-v4-flash-0731.toml create mode 100644 providers/zenifra/models/deepseek-v4-pro.toml create mode 100644 providers/zenifra/models/glm-5.2.toml create mode 100644 providers/zenifra/models/kimi-k2.5.toml create mode 100644 providers/zenifra/models/kimi-k2.7-code.toml create mode 100644 providers/zenifra/models/kimi-k3.toml create mode 100644 providers/zenifra/models/qwen3.7-max.toml create mode 100644 providers/zenifra/models/qwen3.7-plus.toml create mode 100644 providers/zenifra/models/qwen3.8-flash.toml create mode 100644 providers/zenifra/models/qwen3.8-max.toml diff --git a/providers/zenifra/models/deepseek-v4-flash-0731.toml b/providers/zenifra/models/deepseek-v4-flash-0731.toml new file mode 100644 index 00000000000..3e1072f12c2 --- /dev/null +++ b/providers/zenifra/models/deepseek-v4-flash-0731.toml @@ -0,0 +1,18 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Reasoning: reasoning_effort = low|medium|high|xhigh|max +base_model = "deepseek/deepseek-v4-flash-0731" +structured_output = false +reasoning_options = [ + { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }, +] + +[limit] +output = 393_216 + +[provider] +shape = "completions" + +[cost] +input = 2.2 +output = 6.6 +cache_read = 0.29 diff --git a/providers/zenifra/models/deepseek-v4-pro.toml b/providers/zenifra/models/deepseek-v4-pro.toml new file mode 100644 index 00000000000..9e27858e24d --- /dev/null +++ b/providers/zenifra/models/deepseek-v4-pro.toml @@ -0,0 +1,18 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Reasoning: reasoning_effort = low|medium|high|xhigh|max +base_model = "deepseek/deepseek-v4-pro" +structured_output = false +reasoning_options = [ + { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }, +] + +[limit] +output = 393_216 + +[provider] +shape = "completions" + +[cost] +input = 8.5 +output = 17.0 +cache_read = 0.7 diff --git a/providers/zenifra/models/glm-5.2.toml b/providers/zenifra/models/glm-5.2.toml new file mode 100644 index 00000000000..2a65c95636e --- /dev/null +++ b/providers/zenifra/models/glm-5.2.toml @@ -0,0 +1,18 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Reasoning: reasoning_effort = low|medium|high|xhigh|max +base_model = "zhipuai/glm-5.2" +structured_output = false +reasoning_options = [ + { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }, +] + +[limit] +context = 1_048_576 + +[provider] +shape = "completions" + +[cost] +input = 5.7 +output = 20.0 +cache_read = 1.4 diff --git a/providers/zenifra/models/kimi-k2.5.toml b/providers/zenifra/models/kimi-k2.5.toml new file mode 100644 index 00000000000..642cfd1fdf0 --- /dev/null +++ b/providers/zenifra/models/kimi-k2.5.toml @@ -0,0 +1,18 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +base_model = "moonshotai/kimi-k2.5" +attachment = false +temperature = true +structured_output = false +reasoning_options = [] + +[limit] +context = 229_376 +output = 16_384 + +[provider] +shape = "completions" + +[cost] +input = 3.0 +output = 15.7 +cache_read = 0.6 diff --git a/providers/zenifra/models/kimi-k2.7-code.toml b/providers/zenifra/models/kimi-k2.7-code.toml new file mode 100644 index 00000000000..f02974f9388 --- /dev/null +++ b/providers/zenifra/models/kimi-k2.7-code.toml @@ -0,0 +1,17 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +base_model = "moonshotai/kimi-k2.7-code" +attachment = false +temperature = true +reasoning_options = [] + +[limit] +context = 229_376 +output = 16_384 + +[provider] +shape = "completions" + +[cost] +input = 4.7 +output = 19.4 +cache_read = 0.35 diff --git a/providers/zenifra/models/kimi-k3.toml b/providers/zenifra/models/kimi-k3.toml new file mode 100644 index 00000000000..7c928b0f881 --- /dev/null +++ b/providers/zenifra/models/kimi-k3.toml @@ -0,0 +1,17 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Reasoning is always on; Zenifra advertises effort levels low|high|max. +base_model = "moonshotai/kimi-k3" +attachment = false +temperature = true +reasoning_options = [{ type = "effort", values = ["low", "high", "max"] }] + +[limit] +output = 1_048_576 + +[provider] +shape = "completions" + +[cost] +input = 14.7 +output = 73.49 +cache_read = 1.5 diff --git a/providers/zenifra/models/qwen3.7-max.toml b/providers/zenifra/models/qwen3.7-max.toml new file mode 100644 index 00000000000..4d3116599de --- /dev/null +++ b/providers/zenifra/models/qwen3.7-max.toml @@ -0,0 +1,15 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +base_model = "alibaba/qwen3.7-max" +structured_output = true +reasoning_options = [] + +[limit] +output = 131_072 + +[provider] +shape = "completions" + +[cost] +input = 8.5 +output = 25.5 +cache_read = 1.7 diff --git a/providers/zenifra/models/qwen3.7-plus.toml b/providers/zenifra/models/qwen3.7-plus.toml new file mode 100644 index 00000000000..835c076753f --- /dev/null +++ b/providers/zenifra/models/qwen3.7-plus.toml @@ -0,0 +1,22 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +base_model = "alibaba/qwen3.7-plus" +attachment = false +structured_output = true +reasoning_options = [] + +[limit] +output = 131_072 + +[provider] +shape = "completions" + +[cost] +input = 1.5 +output = 5.7 +cache_read = 0.4 + +[[cost.tiers]] +tier = { type = "context", size = 256_001 } +input = 4.4 +output = 17.2 +cache_read = 0.9 diff --git a/providers/zenifra/models/qwen3.8-27b.toml b/providers/zenifra/models/qwen3.8-27b.toml index 7ffeda84317..b1091a0ac96 100644 --- a/providers/zenifra/models/qwen3.8-27b.toml +++ b/providers/zenifra/models/qwen3.8-27b.toml @@ -1,15 +1,16 @@ -# Reasoning: reasoning_effort = low|medium|xhigh +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Reasoning: reasoning_effort = none|minimal|low|medium|high|xhigh|max base_model = "alibaba/qwen3.8-27b" attachment = false -reasoning_options = [{ type = "effort", values = ["low", "medium", "xhigh"] }] - -[modalities] -input = ["text"] +structured_output = false +reasoning_options = [ + { type = "effort", values = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }, +] [provider] shape = "completions" [cost] -input = 0.30 -output = 0.90 -cache_read = 0.05 +input = 1.2 +output = 4.5 +cache_read = 0.3 diff --git a/providers/zenifra/models/qwen3.8-flash.toml b/providers/zenifra/models/qwen3.8-flash.toml new file mode 100644 index 00000000000..71259043e83 --- /dev/null +++ b/providers/zenifra/models/qwen3.8-flash.toml @@ -0,0 +1,16 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# This model advertises both /v1/chat/completions and /v1/responses. +# Reasoning: reasoning_effort = none|minimal|low|medium|high|xhigh|max +base_model = "alibaba/qwen3.8-flash" +attachment = false +reasoning_options = [ + { type = "effort", values = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }, +] + +[provider] +shape = "completions" + +[cost] +input = 0.6 +output = 2.0 +cache_read = 0.1 diff --git a/providers/zenifra/models/qwen3.8-max.toml b/providers/zenifra/models/qwen3.8-max.toml new file mode 100644 index 00000000000..4dfab28a187 --- /dev/null +++ b/providers/zenifra/models/qwen3.8-max.toml @@ -0,0 +1,17 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# This model advertises both /v1/chat/completions and /v1/responses. +# Reasoning: reasoning_effort = none|minimal|low|medium|high|xhigh|max +base_model = "alibaba/qwen3.8-max" +attachment = false +structured_output = true +reasoning_options = [ + { type = "effort", values = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }, +] + +[provider] +shape = "completions" + +[cost] +input = 8.5 +output = 25.6 +cache_read = 0.7 From 472aaeb3658c465e51b61ce947f19fd586da60fc Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sat, 12 Sep 2026 11:50:01 -0300 Subject: [PATCH 04/14] fix(zenifra): convert model costs to USD --- .../zenifra/models/deepseek-v4-flash-0731.toml | 7 ++++--- providers/zenifra/models/deepseek-v4-pro.toml | 7 ++++--- providers/zenifra/models/glm-5.2.toml | 7 ++++--- providers/zenifra/models/kimi-k2.5.toml | 7 ++++--- providers/zenifra/models/kimi-k2.7-code.toml | 7 ++++--- providers/zenifra/models/kimi-k3.toml | 7 ++++--- providers/zenifra/models/qwen3.7-max.toml | 7 ++++--- providers/zenifra/models/qwen3.7-plus.toml | 13 +++++++------ providers/zenifra/models/qwen3.8-27b.toml | 7 ++++--- providers/zenifra/models/qwen3.8-flash.toml | 7 ++++--- providers/zenifra/models/qwen3.8-max.toml | 7 ++++--- 11 files changed, 47 insertions(+), 36 deletions(-) diff --git a/providers/zenifra/models/deepseek-v4-flash-0731.toml b/providers/zenifra/models/deepseek-v4-flash-0731.toml index 3e1072f12c2..42a623bf3c6 100644 --- a/providers/zenifra/models/deepseek-v4-flash-0731.toml +++ b/providers/zenifra/models/deepseek-v4-flash-0731.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Reasoning: reasoning_effort = low|medium|high|xhigh|max base_model = "deepseek/deepseek-v4-flash-0731" structured_output = false @@ -13,6 +14,6 @@ output = 393_216 shape = "completions" [cost] -input = 2.2 -output = 6.6 -cache_read = 0.29 +input = 0.423077 +output = 1.269231 +cache_read = 0.055769 diff --git a/providers/zenifra/models/deepseek-v4-pro.toml b/providers/zenifra/models/deepseek-v4-pro.toml index 9e27858e24d..1572136344c 100644 --- a/providers/zenifra/models/deepseek-v4-pro.toml +++ b/providers/zenifra/models/deepseek-v4-pro.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Reasoning: reasoning_effort = low|medium|high|xhigh|max base_model = "deepseek/deepseek-v4-pro" structured_output = false @@ -13,6 +14,6 @@ output = 393_216 shape = "completions" [cost] -input = 8.5 -output = 17.0 -cache_read = 0.7 +input = 1.634615 +output = 3.269231 +cache_read = 0.134615 diff --git a/providers/zenifra/models/glm-5.2.toml b/providers/zenifra/models/glm-5.2.toml index 2a65c95636e..d78d19c059a 100644 --- a/providers/zenifra/models/glm-5.2.toml +++ b/providers/zenifra/models/glm-5.2.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Reasoning: reasoning_effort = low|medium|high|xhigh|max base_model = "zhipuai/glm-5.2" structured_output = false @@ -13,6 +14,6 @@ context = 1_048_576 shape = "completions" [cost] -input = 5.7 -output = 20.0 -cache_read = 1.4 +input = 1.096154 +output = 3.846154 +cache_read = 0.269231 diff --git a/providers/zenifra/models/kimi-k2.5.toml b/providers/zenifra/models/kimi-k2.5.toml index 642cfd1fdf0..3664e38fe41 100644 --- a/providers/zenifra/models/kimi-k2.5.toml +++ b/providers/zenifra/models/kimi-k2.5.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). base_model = "moonshotai/kimi-k2.5" attachment = false temperature = true @@ -13,6 +14,6 @@ output = 16_384 shape = "completions" [cost] -input = 3.0 -output = 15.7 -cache_read = 0.6 +input = 0.576923 +output = 3.019231 +cache_read = 0.115385 diff --git a/providers/zenifra/models/kimi-k2.7-code.toml b/providers/zenifra/models/kimi-k2.7-code.toml index f02974f9388..b59ee26da14 100644 --- a/providers/zenifra/models/kimi-k2.7-code.toml +++ b/providers/zenifra/models/kimi-k2.7-code.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). base_model = "moonshotai/kimi-k2.7-code" attachment = false temperature = true @@ -12,6 +13,6 @@ output = 16_384 shape = "completions" [cost] -input = 4.7 -output = 19.4 -cache_read = 0.35 +input = 0.903846 +output = 3.730769 +cache_read = 0.067308 diff --git a/providers/zenifra/models/kimi-k3.toml b/providers/zenifra/models/kimi-k3.toml index 7c928b0f881..bf9884a5d37 100644 --- a/providers/zenifra/models/kimi-k3.toml +++ b/providers/zenifra/models/kimi-k3.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Reasoning is always on; Zenifra advertises effort levels low|high|max. base_model = "moonshotai/kimi-k3" attachment = false @@ -12,6 +13,6 @@ output = 1_048_576 shape = "completions" [cost] -input = 14.7 -output = 73.49 -cache_read = 1.5 +input = 2.826923 +output = 14.132692 +cache_read = 0.288462 diff --git a/providers/zenifra/models/qwen3.7-max.toml b/providers/zenifra/models/qwen3.7-max.toml index 4d3116599de..4db8826edc3 100644 --- a/providers/zenifra/models/qwen3.7-max.toml +++ b/providers/zenifra/models/qwen3.7-max.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). base_model = "alibaba/qwen3.7-max" structured_output = true reasoning_options = [] @@ -10,6 +11,6 @@ output = 131_072 shape = "completions" [cost] -input = 8.5 -output = 25.5 -cache_read = 1.7 +input = 1.634615 +output = 4.903846 +cache_read = 0.326923 diff --git a/providers/zenifra/models/qwen3.7-plus.toml b/providers/zenifra/models/qwen3.7-plus.toml index 835c076753f..39c402aeb25 100644 --- a/providers/zenifra/models/qwen3.7-plus.toml +++ b/providers/zenifra/models/qwen3.7-plus.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). base_model = "alibaba/qwen3.7-plus" attachment = false structured_output = true @@ -11,12 +12,12 @@ output = 131_072 shape = "completions" [cost] -input = 1.5 -output = 5.7 -cache_read = 0.4 +input = 0.288462 +output = 1.096154 +cache_read = 0.076923 [[cost.tiers]] tier = { type = "context", size = 256_001 } -input = 4.4 -output = 17.2 -cache_read = 0.9 +input = 0.846154 +output = 3.307692 +cache_read = 0.173077 diff --git a/providers/zenifra/models/qwen3.8-27b.toml b/providers/zenifra/models/qwen3.8-27b.toml index b1091a0ac96..f07247337ab 100644 --- a/providers/zenifra/models/qwen3.8-27b.toml +++ b/providers/zenifra/models/qwen3.8-27b.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Reasoning: reasoning_effort = none|minimal|low|medium|high|xhigh|max base_model = "alibaba/qwen3.8-27b" attachment = false @@ -11,6 +12,6 @@ reasoning_options = [ shape = "completions" [cost] -input = 1.2 -output = 4.5 -cache_read = 0.3 +input = 0.230769 +output = 0.865385 +cache_read = 0.057692 diff --git a/providers/zenifra/models/qwen3.8-flash.toml b/providers/zenifra/models/qwen3.8-flash.toml index 71259043e83..1292c9729d7 100644 --- a/providers/zenifra/models/qwen3.8-flash.toml +++ b/providers/zenifra/models/qwen3.8-flash.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # This model advertises both /v1/chat/completions and /v1/responses. # Reasoning: reasoning_effort = none|minimal|low|medium|high|xhigh|max base_model = "alibaba/qwen3.8-flash" @@ -11,6 +12,6 @@ reasoning_options = [ shape = "completions" [cost] -input = 0.6 -output = 2.0 -cache_read = 0.1 +input = 0.115385 +output = 0.384615 +cache_read = 0.019231 diff --git a/providers/zenifra/models/qwen3.8-max.toml b/providers/zenifra/models/qwen3.8-max.toml index 4dfab28a187..6eed1a87043 100644 --- a/providers/zenifra/models/qwen3.8-max.toml +++ b/providers/zenifra/models/qwen3.8-max.toml @@ -1,4 +1,5 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # This model advertises both /v1/chat/completions and /v1/responses. # Reasoning: reasoning_effort = none|minimal|low|medium|high|xhigh|max base_model = "alibaba/qwen3.8-max" @@ -12,6 +13,6 @@ reasoning_options = [ shape = "completions" [cost] -input = 8.5 -output = 25.6 -cache_read = 0.7 +input = 1.634615 +output = 4.923077 +cache_read = 0.134615 From 815113e0df23f1bba1661c2a227e2b0837dac8e6 Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sat, 12 Sep 2026 12:27:06 -0300 Subject: [PATCH 05/14] fix(zenifra): align hosted model metadata --- providers/zenifra/models/deepseek-v4-flash-0731.toml | 6 ++++-- providers/zenifra/models/deepseek-v4-pro.toml | 6 ++++-- providers/zenifra/models/glm-5.2.toml | 4 ++-- providers/zenifra/models/kimi-k2.5.toml | 2 ++ providers/zenifra/models/kimi-k2.7-code.toml | 1 + providers/zenifra/models/qwen3.7-max.toml | 2 ++ providers/zenifra/models/qwen3.7-plus.toml | 2 ++ providers/zenifra/models/qwen3.8-27b.toml | 1 + providers/zenifra/models/qwen3.8-max.toml | 2 +- 9 files changed, 19 insertions(+), 7 deletions(-) diff --git a/providers/zenifra/models/deepseek-v4-flash-0731.toml b/providers/zenifra/models/deepseek-v4-flash-0731.toml index 42a623bf3c6..12b2c82e520 100644 --- a/providers/zenifra/models/deepseek-v4-flash-0731.toml +++ b/providers/zenifra/models/deepseek-v4-flash-0731.toml @@ -1,10 +1,12 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). -# Reasoning: reasoning_effort = low|medium|high|xhigh|max +# Reasoning follows the native DeepSeek controls: thinking.type = enabled|disabled; +# reasoning_effort = high|max. base_model = "deepseek/deepseek-v4-flash-0731" structured_output = false reasoning_options = [ - { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }, + { type = "toggle" }, + { type = "effort", values = ["high", "max"] }, ] [limit] diff --git a/providers/zenifra/models/deepseek-v4-pro.toml b/providers/zenifra/models/deepseek-v4-pro.toml index 1572136344c..c2ae741b580 100644 --- a/providers/zenifra/models/deepseek-v4-pro.toml +++ b/providers/zenifra/models/deepseek-v4-pro.toml @@ -1,10 +1,12 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). -# Reasoning: reasoning_effort = low|medium|high|xhigh|max +# Reasoning follows the native DeepSeek controls: thinking.type = enabled|disabled; +# reasoning_effort = high|max. base_model = "deepseek/deepseek-v4-pro" structured_output = false reasoning_options = [ - { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }, + { type = "toggle" }, + { type = "effort", values = ["high", "max"] }, ] [limit] diff --git a/providers/zenifra/models/glm-5.2.toml b/providers/zenifra/models/glm-5.2.toml index d78d19c059a..f73211faca4 100644 --- a/providers/zenifra/models/glm-5.2.toml +++ b/providers/zenifra/models/glm-5.2.toml @@ -1,10 +1,10 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). -# Reasoning: reasoning_effort = low|medium|high|xhigh|max +# Reasoning follows the native GLM controls: effective reasoning_effort = high|max. base_model = "zhipuai/glm-5.2" structured_output = false reasoning_options = [ - { type = "effort", values = ["low", "medium", "high", "xhigh", "max"] }, + { type = "effort", values = ["high", "max"] }, ] [limit] diff --git a/providers/zenifra/models/kimi-k2.5.toml b/providers/zenifra/models/kimi-k2.5.toml index 3664e38fe41..1a52077a470 100644 --- a/providers/zenifra/models/kimi-k2.5.toml +++ b/providers/zenifra/models/kimi-k2.5.toml @@ -1,5 +1,7 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Capabilities: structured_outputs = false. +# Reasoning is supported but no caller control is listed in supported_parameters. base_model = "moonshotai/kimi-k2.5" attachment = false temperature = true diff --git a/providers/zenifra/models/kimi-k2.7-code.toml b/providers/zenifra/models/kimi-k2.7-code.toml index b59ee26da14..4415fe2011a 100644 --- a/providers/zenifra/models/kimi-k2.7-code.toml +++ b/providers/zenifra/models/kimi-k2.7-code.toml @@ -1,5 +1,6 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Reasoning is always on and no caller control is listed in supported_parameters. base_model = "moonshotai/kimi-k2.7-code" attachment = false temperature = true diff --git a/providers/zenifra/models/qwen3.7-max.toml b/providers/zenifra/models/qwen3.7-max.toml index 4db8826edc3..26083cb3b90 100644 --- a/providers/zenifra/models/qwen3.7-max.toml +++ b/providers/zenifra/models/qwen3.7-max.toml @@ -1,5 +1,7 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Capabilities: structured_outputs = true; input_modalities = text. +# Reasoning is supported but no caller control is listed in supported_parameters. base_model = "alibaba/qwen3.7-max" structured_output = true reasoning_options = [] diff --git a/providers/zenifra/models/qwen3.7-plus.toml b/providers/zenifra/models/qwen3.7-plus.toml index 39c402aeb25..916f1a95c63 100644 --- a/providers/zenifra/models/qwen3.7-plus.toml +++ b/providers/zenifra/models/qwen3.7-plus.toml @@ -1,5 +1,7 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Capabilities: structured_outputs = true. +# Reasoning is supported but no caller control is listed in supported_parameters. base_model = "alibaba/qwen3.7-plus" attachment = false structured_output = true diff --git a/providers/zenifra/models/qwen3.8-27b.toml b/providers/zenifra/models/qwen3.8-27b.toml index f07247337ab..34411da63a6 100644 --- a/providers/zenifra/models/qwen3.8-27b.toml +++ b/providers/zenifra/models/qwen3.8-27b.toml @@ -1,5 +1,6 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Capabilities: structured_outputs = false. # Reasoning: reasoning_effort = none|minimal|low|medium|high|xhigh|max base_model = "alibaba/qwen3.8-27b" attachment = false diff --git a/providers/zenifra/models/qwen3.8-max.toml b/providers/zenifra/models/qwen3.8-max.toml index 6eed1a87043..07aa28749f5 100644 --- a/providers/zenifra/models/qwen3.8-max.toml +++ b/providers/zenifra/models/qwen3.8-max.toml @@ -15,4 +15,4 @@ shape = "completions" [cost] input = 1.634615 output = 4.923077 -cache_read = 0.134615 +cache_read = 0.211538 From 107a6c064d7d1ef1c2318928c8b3686058422f2d Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sat, 12 Sep 2026 18:52:42 -0300 Subject: [PATCH 06/14] fix(zenifra): align native reasoning controls --- packages/core/test/generate.test.ts | 56 +++++++++++++++++++ .../{ => zenifra}/deepseek-v4-flash-0731.toml | 6 +- .../models/{ => zenifra}/deepseek-v4-pro.toml | 8 +-- .../zenifra/models/{ => zenifra}/glm-5.2.toml | 4 +- .../models/{ => zenifra}/kimi-k2.5.toml | 4 +- .../models/{ => zenifra}/kimi-k2.7-code.toml | 0 .../zenifra/models/{ => zenifra}/kimi-k3.toml | 0 .../models/{ => zenifra}/qwen3.7-max.toml | 4 +- .../models/{ => zenifra}/qwen3.7-plus.toml | 4 +- .../models/{ => zenifra}/qwen3.8-27b.toml | 6 +- .../models/{ => zenifra}/qwen3.8-flash.toml | 6 +- .../models/{ => zenifra}/qwen3.8-max.toml | 6 +- 12 files changed, 84 insertions(+), 20 deletions(-) rename providers/zenifra/models/{ => zenifra}/deepseek-v4-flash-0731.toml (72%) rename providers/zenifra/models/{ => zenifra}/deepseek-v4-pro.toml (65%) rename providers/zenifra/models/{ => zenifra}/glm-5.2.toml (81%) rename providers/zenifra/models/{ => zenifra}/kimi-k2.5.toml (80%) rename providers/zenifra/models/{ => zenifra}/kimi-k2.7-code.toml (100%) rename providers/zenifra/models/{ => zenifra}/kimi-k3.toml (100%) rename providers/zenifra/models/{ => zenifra}/qwen3.7-max.toml (78%) rename providers/zenifra/models/{ => zenifra}/qwen3.7-plus.toml (82%) rename providers/zenifra/models/{ => zenifra}/qwen3.8-27b.toml (71%) rename providers/zenifra/models/{ => zenifra}/qwen3.8-flash.toml (71%) rename providers/zenifra/models/{ => zenifra}/qwen3.8-max.toml (72%) diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index 9ce75af62f6..30a817774db 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -45,6 +45,62 @@ describe("catalog generation", () => { }); }); + test("Zenifra model IDs preserve the upstream namespace", async () => { + const root = path.join(import.meta.dirname, "..", "..", ".."); + const providers = await generate(path.join(root, "providers")); + const modelIDs = Object.keys(providers.zenifra?.models ?? {}); + + expect(modelIDs).not.toEqual([]); + expect(modelIDs.every((modelID) => modelID.startsWith("zenifra/"))).toBe(true); + }); + + test("Zenifra exposes only native reasoning controls", async () => { + const root = path.join(import.meta.dirname, "..", "..", ".."); + const providers = await generate(path.join(root, "providers")); + const models = providers.zenifra?.models ?? {}; + + const actual = Object.fromEntries( + Object.entries(models).map(([modelID, model]) => [ + modelID, + model.reasoning_options, + ]), + ); + + expect(actual).toEqual({ + "zenifra/deepseek-v4-flash-0731": [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ], + "zenifra/deepseek-v4-pro": [ + { type: "toggle" }, + { type: "effort", values: ["low", "high", "max"] }, + ], + "zenifra/glm-5.2": [ + { type: "toggle" }, + { type: "effort", values: ["high", "max"] }, + ], + "zenifra/kimi-k2.5": [{ type: "toggle" }], + "zenifra/kimi-k2.7-code": [], + "zenifra/kimi-k3": [ + { type: "effort", values: ["low", "high", "max"] }, + ], + "zenifra/qwen3.7-max": [{ type: "toggle" }], + "zenifra/qwen3.7-plus": [{ type: "toggle" }], + "zenifra/qwen3.8-27b": [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + ], + "zenifra/qwen3.8-flash": [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + ], + "zenifra/qwen3.8-max": [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + ], + }); + }); + test("base_model can factor metadata without changing provider JSON", async () => { await withFixture(async (root) => { await write(root, "providers/direct/provider.toml", providerToml("Direct")); diff --git a/providers/zenifra/models/deepseek-v4-flash-0731.toml b/providers/zenifra/models/zenifra/deepseek-v4-flash-0731.toml similarity index 72% rename from providers/zenifra/models/deepseek-v4-flash-0731.toml rename to providers/zenifra/models/zenifra/deepseek-v4-flash-0731.toml index 12b2c82e520..8f616146dca 100644 --- a/providers/zenifra/models/deepseek-v4-flash-0731.toml +++ b/providers/zenifra/models/zenifra/deepseek-v4-flash-0731.toml @@ -1,12 +1,12 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). -# Reasoning follows the native DeepSeek controls: thinking.type = enabled|disabled; -# reasoning_effort = high|max. +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = low|high|max. base_model = "deepseek/deepseek-v4-flash-0731" structured_output = false reasoning_options = [ { type = "toggle" }, - { type = "effort", values = ["high", "max"] }, + { type = "effort", values = ["low", "high", "max"] }, ] [limit] diff --git a/providers/zenifra/models/deepseek-v4-pro.toml b/providers/zenifra/models/zenifra/deepseek-v4-pro.toml similarity index 65% rename from providers/zenifra/models/deepseek-v4-pro.toml rename to providers/zenifra/models/zenifra/deepseek-v4-pro.toml index c2ae741b580..42293608a80 100644 --- a/providers/zenifra/models/deepseek-v4-pro.toml +++ b/providers/zenifra/models/zenifra/deepseek-v4-pro.toml @@ -1,12 +1,12 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). -# Reasoning follows the native DeepSeek controls: thinking.type = enabled|disabled; -# reasoning_effort = high|max. -base_model = "deepseek/deepseek-v4-pro" +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = low|high|max. +base_model = "deepseek/deepseek-v4-pro-0813" structured_output = false reasoning_options = [ { type = "toggle" }, - { type = "effort", values = ["high", "max"] }, + { type = "effort", values = ["low", "high", "max"] }, ] [limit] diff --git a/providers/zenifra/models/glm-5.2.toml b/providers/zenifra/models/zenifra/glm-5.2.toml similarity index 81% rename from providers/zenifra/models/glm-5.2.toml rename to providers/zenifra/models/zenifra/glm-5.2.toml index f73211faca4..01793067392 100644 --- a/providers/zenifra/models/glm-5.2.toml +++ b/providers/zenifra/models/zenifra/glm-5.2.toml @@ -1,9 +1,11 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). -# Reasoning follows the native GLM controls: effective reasoning_effort = high|max. +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = high|max. base_model = "zhipuai/glm-5.2" structured_output = false reasoning_options = [ + { type = "toggle" }, { type = "effort", values = ["high", "max"] }, ] diff --git a/providers/zenifra/models/kimi-k2.5.toml b/providers/zenifra/models/zenifra/kimi-k2.5.toml similarity index 80% rename from providers/zenifra/models/kimi-k2.5.toml rename to providers/zenifra/models/zenifra/kimi-k2.5.toml index 1a52077a470..10601a1ea7e 100644 --- a/providers/zenifra/models/kimi-k2.5.toml +++ b/providers/zenifra/models/zenifra/kimi-k2.5.toml @@ -1,12 +1,12 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Capabilities: structured_outputs = false. -# Reasoning is supported but no caller control is listed in supported_parameters. +# Toggle: enable_thinking = true|false; no native reasoning_effort levels. base_model = "moonshotai/kimi-k2.5" attachment = false temperature = true structured_output = false -reasoning_options = [] +reasoning_options = [{ type = "toggle" }] [limit] context = 229_376 diff --git a/providers/zenifra/models/kimi-k2.7-code.toml b/providers/zenifra/models/zenifra/kimi-k2.7-code.toml similarity index 100% rename from providers/zenifra/models/kimi-k2.7-code.toml rename to providers/zenifra/models/zenifra/kimi-k2.7-code.toml diff --git a/providers/zenifra/models/kimi-k3.toml b/providers/zenifra/models/zenifra/kimi-k3.toml similarity index 100% rename from providers/zenifra/models/kimi-k3.toml rename to providers/zenifra/models/zenifra/kimi-k3.toml diff --git a/providers/zenifra/models/qwen3.7-max.toml b/providers/zenifra/models/zenifra/qwen3.7-max.toml similarity index 78% rename from providers/zenifra/models/qwen3.7-max.toml rename to providers/zenifra/models/zenifra/qwen3.7-max.toml index 26083cb3b90..d7a316afc33 100644 --- a/providers/zenifra/models/qwen3.7-max.toml +++ b/providers/zenifra/models/zenifra/qwen3.7-max.toml @@ -1,10 +1,10 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Capabilities: structured_outputs = true; input_modalities = text. -# Reasoning is supported but no caller control is listed in supported_parameters. +# Toggle: enable_thinking = true|false; no native reasoning_effort levels. base_model = "alibaba/qwen3.7-max" structured_output = true -reasoning_options = [] +reasoning_options = [{ type = "toggle" }] [limit] output = 131_072 diff --git a/providers/zenifra/models/qwen3.7-plus.toml b/providers/zenifra/models/zenifra/qwen3.7-plus.toml similarity index 82% rename from providers/zenifra/models/qwen3.7-plus.toml rename to providers/zenifra/models/zenifra/qwen3.7-plus.toml index 916f1a95c63..924467c5d79 100644 --- a/providers/zenifra/models/qwen3.7-plus.toml +++ b/providers/zenifra/models/zenifra/qwen3.7-plus.toml @@ -1,11 +1,11 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Capabilities: structured_outputs = true. -# Reasoning is supported but no caller control is listed in supported_parameters. +# Toggle: enable_thinking = true|false; no native reasoning_effort levels. base_model = "alibaba/qwen3.7-plus" attachment = false structured_output = true -reasoning_options = [] +reasoning_options = [{ type = "toggle" }] [limit] output = 131_072 diff --git a/providers/zenifra/models/qwen3.8-27b.toml b/providers/zenifra/models/zenifra/qwen3.8-27b.toml similarity index 71% rename from providers/zenifra/models/qwen3.8-27b.toml rename to providers/zenifra/models/zenifra/qwen3.8-27b.toml index 34411da63a6..535adc0fe27 100644 --- a/providers/zenifra/models/qwen3.8-27b.toml +++ b/providers/zenifra/models/zenifra/qwen3.8-27b.toml @@ -1,12 +1,14 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Capabilities: structured_outputs = false. -# Reasoning: reasoning_effort = none|minimal|low|medium|high|xhigh|max +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = low|medium|xhigh. base_model = "alibaba/qwen3.8-27b" attachment = false structured_output = false reasoning_options = [ - { type = "effort", values = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }, + { type = "toggle" }, + { type = "effort", values = ["low", "medium", "xhigh"] }, ] [provider] diff --git a/providers/zenifra/models/qwen3.8-flash.toml b/providers/zenifra/models/zenifra/qwen3.8-flash.toml similarity index 71% rename from providers/zenifra/models/qwen3.8-flash.toml rename to providers/zenifra/models/zenifra/qwen3.8-flash.toml index 1292c9729d7..2ffd7094181 100644 --- a/providers/zenifra/models/qwen3.8-flash.toml +++ b/providers/zenifra/models/zenifra/qwen3.8-flash.toml @@ -1,11 +1,13 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # This model advertises both /v1/chat/completions and /v1/responses. -# Reasoning: reasoning_effort = none|minimal|low|medium|high|xhigh|max +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = low|medium|xhigh. base_model = "alibaba/qwen3.8-flash" attachment = false reasoning_options = [ - { type = "effort", values = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }, + { type = "toggle" }, + { type = "effort", values = ["low", "medium", "xhigh"] }, ] [provider] diff --git a/providers/zenifra/models/qwen3.8-max.toml b/providers/zenifra/models/zenifra/qwen3.8-max.toml similarity index 72% rename from providers/zenifra/models/qwen3.8-max.toml rename to providers/zenifra/models/zenifra/qwen3.8-max.toml index 07aa28749f5..7f90696d282 100644 --- a/providers/zenifra/models/qwen3.8-max.toml +++ b/providers/zenifra/models/zenifra/qwen3.8-max.toml @@ -1,12 +1,14 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # This model advertises both /v1/chat/completions and /v1/responses. -# Reasoning: reasoning_effort = none|minimal|low|medium|high|xhigh|max +# Toggle: enable_thinking = true|false. +# Effort: reasoning_effort = low|medium|xhigh. base_model = "alibaba/qwen3.8-max" attachment = false structured_output = true reasoning_options = [ - { type = "effort", values = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] }, + { type = "toggle" }, + { type = "effort", values = ["low", "medium", "xhigh"] }, ] [provider] From cf5a5dd208d2117951e73fca002919255cf9ad81 Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sat, 12 Sep 2026 19:15:42 -0300 Subject: [PATCH 07/14] fix(zenifra): inherit multimodal attachment support --- packages/core/test/generate.test.ts | 27 +++++++++++++++++++ .../zenifra/models/zenifra/kimi-k2.5.toml | 1 - .../models/zenifra/kimi-k2.7-code.toml | 1 - providers/zenifra/models/zenifra/kimi-k3.toml | 1 - .../zenifra/models/zenifra/qwen3.7-plus.toml | 1 - .../zenifra/models/zenifra/qwen3.8-flash.toml | 1 - .../zenifra/models/zenifra/qwen3.8-max.toml | 1 - 7 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index 30a817774db..f0b648658af 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -101,6 +101,33 @@ describe("catalog generation", () => { }); }); + test("Zenifra attachment support matches its served input modalities", async () => { + const root = path.join(import.meta.dirname, "..", "..", ".."); + const providers = await generate(path.join(root, "providers")); + const models = providers.zenifra?.models ?? {}; + + const actual = Object.fromEntries( + Object.entries(models).map(([modelID, model]) => [ + modelID, + model.attachment, + ]), + ); + + expect(actual).toEqual({ + "zenifra/deepseek-v4-flash-0731": false, + "zenifra/deepseek-v4-pro": false, + "zenifra/glm-5.2": false, + "zenifra/kimi-k2.5": true, + "zenifra/kimi-k2.7-code": true, + "zenifra/kimi-k3": true, + "zenifra/qwen3.7-max": false, + "zenifra/qwen3.7-plus": true, + "zenifra/qwen3.8-27b": false, + "zenifra/qwen3.8-flash": true, + "zenifra/qwen3.8-max": true, + }); + }); + test("base_model can factor metadata without changing provider JSON", async () => { await withFixture(async (root) => { await write(root, "providers/direct/provider.toml", providerToml("Direct")); diff --git a/providers/zenifra/models/zenifra/kimi-k2.5.toml b/providers/zenifra/models/zenifra/kimi-k2.5.toml index 10601a1ea7e..33ca41b3b69 100644 --- a/providers/zenifra/models/zenifra/kimi-k2.5.toml +++ b/providers/zenifra/models/zenifra/kimi-k2.5.toml @@ -3,7 +3,6 @@ # Capabilities: structured_outputs = false. # Toggle: enable_thinking = true|false; no native reasoning_effort levels. base_model = "moonshotai/kimi-k2.5" -attachment = false temperature = true structured_output = false reasoning_options = [{ type = "toggle" }] diff --git a/providers/zenifra/models/zenifra/kimi-k2.7-code.toml b/providers/zenifra/models/zenifra/kimi-k2.7-code.toml index 4415fe2011a..c690be07a8f 100644 --- a/providers/zenifra/models/zenifra/kimi-k2.7-code.toml +++ b/providers/zenifra/models/zenifra/kimi-k2.7-code.toml @@ -2,7 +2,6 @@ # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Reasoning is always on and no caller control is listed in supported_parameters. base_model = "moonshotai/kimi-k2.7-code" -attachment = false temperature = true reasoning_options = [] diff --git a/providers/zenifra/models/zenifra/kimi-k3.toml b/providers/zenifra/models/zenifra/kimi-k3.toml index bf9884a5d37..b107f186347 100644 --- a/providers/zenifra/models/zenifra/kimi-k3.toml +++ b/providers/zenifra/models/zenifra/kimi-k3.toml @@ -2,7 +2,6 @@ # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Reasoning is always on; Zenifra advertises effort levels low|high|max. base_model = "moonshotai/kimi-k3" -attachment = false temperature = true reasoning_options = [{ type = "effort", values = ["low", "high", "max"] }] diff --git a/providers/zenifra/models/zenifra/qwen3.7-plus.toml b/providers/zenifra/models/zenifra/qwen3.7-plus.toml index 924467c5d79..b32c2e3f76c 100644 --- a/providers/zenifra/models/zenifra/qwen3.7-plus.toml +++ b/providers/zenifra/models/zenifra/qwen3.7-plus.toml @@ -3,7 +3,6 @@ # Capabilities: structured_outputs = true. # Toggle: enable_thinking = true|false; no native reasoning_effort levels. base_model = "alibaba/qwen3.7-plus" -attachment = false structured_output = true reasoning_options = [{ type = "toggle" }] diff --git a/providers/zenifra/models/zenifra/qwen3.8-flash.toml b/providers/zenifra/models/zenifra/qwen3.8-flash.toml index 2ffd7094181..5b366cff38b 100644 --- a/providers/zenifra/models/zenifra/qwen3.8-flash.toml +++ b/providers/zenifra/models/zenifra/qwen3.8-flash.toml @@ -4,7 +4,6 @@ # Toggle: enable_thinking = true|false. # Effort: reasoning_effort = low|medium|xhigh. base_model = "alibaba/qwen3.8-flash" -attachment = false reasoning_options = [ { type = "toggle" }, { type = "effort", values = ["low", "medium", "xhigh"] }, diff --git a/providers/zenifra/models/zenifra/qwen3.8-max.toml b/providers/zenifra/models/zenifra/qwen3.8-max.toml index 7f90696d282..26b996f8e1e 100644 --- a/providers/zenifra/models/zenifra/qwen3.8-max.toml +++ b/providers/zenifra/models/zenifra/qwen3.8-max.toml @@ -4,7 +4,6 @@ # Toggle: enable_thinking = true|false. # Effort: reasoning_effort = low|medium|xhigh. base_model = "alibaba/qwen3.8-max" -attachment = false structured_output = true reasoning_options = [ { type = "toggle" }, From 7e49b55b23faa87ad5ccb1dbf042276aa8ca2f28 Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Tue, 22 Sep 2026 20:10:25 -0300 Subject: [PATCH 08/14] fix(zenifra): sync hosted model catalog and pricing --- packages/core/test/generate.test.ts | 73 ++++++++++++++++++- .../zenifra/deepseek-v4-flash-0731.toml | 2 + .../models/zenifra/deepseek-v4-pro.toml | 6 +- providers/zenifra/models/zenifra/glm-5.1.toml | 22 ++++++ .../zenifra/models/zenifra/qwen3.6-flash.toml | 22 ++++++ .../zenifra/models/zenifra/qwen3.6-plus.toml | 22 ++++++ .../zenifra/models/zenifra/qwen3.8-27b.toml | 3 + .../zenifra/models/zenifra/qwen3.8-max.toml | 3 + 8 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 providers/zenifra/models/zenifra/glm-5.1.toml create mode 100644 providers/zenifra/models/zenifra/qwen3.6-flash.toml create mode 100644 providers/zenifra/models/zenifra/qwen3.6-plus.toml diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index f0b648658af..3d33ae3c84a 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -73,8 +73,9 @@ describe("catalog generation", () => { ], "zenifra/deepseek-v4-pro": [ { type: "toggle" }, - { type: "effort", values: ["low", "high", "max"] }, + { type: "effort", values: ["high", "max"] }, ], + "zenifra/glm-5.1": [{ type: "toggle" }], "zenifra/glm-5.2": [ { type: "toggle" }, { type: "effort", values: ["high", "max"] }, @@ -84,6 +85,8 @@ describe("catalog generation", () => { "zenifra/kimi-k3": [ { type: "effort", values: ["low", "high", "max"] }, ], + "zenifra/qwen3.6-flash": [{ type: "toggle" }], + "zenifra/qwen3.6-plus": [{ type: "toggle" }], "zenifra/qwen3.7-max": [{ type: "toggle" }], "zenifra/qwen3.7-plus": [{ type: "toggle" }], "zenifra/qwen3.8-27b": [ @@ -116,10 +119,13 @@ describe("catalog generation", () => { expect(actual).toEqual({ "zenifra/deepseek-v4-flash-0731": false, "zenifra/deepseek-v4-pro": false, + "zenifra/glm-5.1": false, "zenifra/glm-5.2": false, "zenifra/kimi-k2.5": true, "zenifra/kimi-k2.7-code": true, "zenifra/kimi-k3": true, + "zenifra/qwen3.6-flash": true, + "zenifra/qwen3.6-plus": true, "zenifra/qwen3.7-max": false, "zenifra/qwen3.7-plus": true, "zenifra/qwen3.8-27b": false, @@ -128,6 +134,71 @@ describe("catalog generation", () => { }); }); + test("Zenifra serves current limits, modalities, and converted tier prices", async () => { + const root = path.join(import.meta.dirname, "..", "..", ".."); + const providers = await generate(path.join(root, "providers")); + const models = providers.zenifra?.models ?? {}; + + expect(models["zenifra/glm-5.1"]?.limit).toEqual({ + context: 202_745, + output: 131_072, + }); + expect(models["zenifra/qwen3.6-flash"]?.limit).toEqual({ + context: 991_808, + output: 65_536, + }); + expect(models["zenifra/qwen3.6-plus"]?.limit).toEqual({ + context: 991_808, + output: 65_536, + }); + expect(models["zenifra/qwen3.8-27b"]?.modalities?.input).toEqual(["text"]); + expect(models["zenifra/qwen3.8-max"]?.modalities?.input).toEqual(["text", "image", "video"]); + + expect(models["zenifra/glm-5.1"]?.cost).toEqual({ + input: 1.144231, + output: 4.578846, + cache_read: 0.228846, + tiers: [{ + tier: { type: "context", size: 32_001 }, + input: 1.526923, + output: 5.340385, + cache_read: 0.305769, + }], + }); + expect(models["zenifra/qwen3.6-flash"]?.cost).toEqual({ + input: 0.163462, + output: 0.980769, + cache_read: 0.017308, + context_over_200k: { + input: 0.653846, + output: 3.923077, + cache_read: 0.065385, + }, + tiers: [{ + tier: { type: "context", size: 256_001 }, + input: 0.653846, + output: 3.923077, + cache_read: 0.065385, + }], + }); + expect(models["zenifra/qwen3.6-plus"]?.cost).toEqual({ + input: 0.275, + output: 1.636538, + cache_read: 0.028846, + context_over_200k: { + input: 1.092308, + output: 6.540385, + cache_read: 0.111538, + }, + tiers: [{ + tier: { type: "context", size: 256_001 }, + input: 1.092308, + output: 6.540385, + cache_read: 0.111538, + }], + }); + }); + test("base_model can factor metadata without changing provider JSON", async () => { await withFixture(async (root) => { await write(root, "providers/direct/provider.toml", providerToml("Direct")); diff --git a/providers/zenifra/models/zenifra/deepseek-v4-flash-0731.toml b/providers/zenifra/models/zenifra/deepseek-v4-flash-0731.toml index 8f616146dca..3e7de452fdb 100644 --- a/providers/zenifra/models/zenifra/deepseek-v4-flash-0731.toml +++ b/providers/zenifra/models/zenifra/deepseek-v4-flash-0731.toml @@ -1,5 +1,7 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). +# Off-peak 14:00-00:00 UTC: BRL/1M input 1.10, output 3.30, cache read 0.14; +# the models.dev cost schema does not represent time-of-day pricing. # Toggle: enable_thinking = true|false. # Effort: reasoning_effort = low|high|max. base_model = "deepseek/deepseek-v4-flash-0731" diff --git a/providers/zenifra/models/zenifra/deepseek-v4-pro.toml b/providers/zenifra/models/zenifra/deepseek-v4-pro.toml index 42293608a80..ed15a9ae266 100644 --- a/providers/zenifra/models/zenifra/deepseek-v4-pro.toml +++ b/providers/zenifra/models/zenifra/deepseek-v4-pro.toml @@ -1,12 +1,14 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). # Toggle: enable_thinking = true|false. -# Effort: reasoning_effort = low|high|max. +# Effort: reasoning_effort = high|max (GET /v1/models, 2026-09-22). +# Off-peak 14:00-00:00 UTC: BRL/1M input 4.30, output 8.50, cache read 0.35; +# the models.dev cost schema does not represent time-of-day pricing. base_model = "deepseek/deepseek-v4-pro-0813" structured_output = false reasoning_options = [ { type = "toggle" }, - { type = "effort", values = ["low", "high", "max"] }, + { type = "effort", values = ["high", "max"] }, ] [limit] diff --git a/providers/zenifra/models/zenifra/glm-5.1.toml b/providers/zenifra/models/zenifra/glm-5.1.toml new file mode 100644 index 00000000000..71293d63f88 --- /dev/null +++ b/providers/zenifra/models/zenifra/glm-5.1.toml @@ -0,0 +1,22 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-22). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate). +# No native reasoning_effort levels advertised; expose the model's thinking toggle only. +base_model = "zhipuai/glm-5.1" +reasoning_options = [{ type = "toggle" }] + +[limit] +context = 202_745 + +[provider] +shape = "completions" + +[cost] +input = 1.144231 +output = 4.578846 +cache_read = 0.228846 + +[[cost.tiers]] +tier = { type = "context", size = 32_001 } +input = 1.526923 +output = 5.340385 +cache_read = 0.305769 diff --git a/providers/zenifra/models/zenifra/qwen3.6-flash.toml b/providers/zenifra/models/zenifra/qwen3.6-flash.toml new file mode 100644 index 00000000000..95cc2365026 --- /dev/null +++ b/providers/zenifra/models/zenifra/qwen3.6-flash.toml @@ -0,0 +1,22 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-22). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate). +# No native reasoning_effort levels advertised; expose the model's thinking toggle only. +base_model = "alibaba/qwen3.6-flash" +reasoning_options = [{ type = "toggle" }] + +[limit] +context = 991_808 + +[provider] +shape = "completions" + +[cost] +input = 0.163462 +output = 0.980769 +cache_read = 0.017308 + +[[cost.tiers]] +tier = { type = "context", size = 256_001 } +input = 0.653846 +output = 3.923077 +cache_read = 0.065385 diff --git a/providers/zenifra/models/zenifra/qwen3.6-plus.toml b/providers/zenifra/models/zenifra/qwen3.6-plus.toml new file mode 100644 index 00000000000..c340d150091 --- /dev/null +++ b/providers/zenifra/models/zenifra/qwen3.6-plus.toml @@ -0,0 +1,22 @@ +# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-22). +# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate). +# No native reasoning_effort levels advertised; expose the model's thinking toggle only. +base_model = "alibaba/qwen3.6-plus" +reasoning_options = [{ type = "toggle" }] + +[limit] +context = 991_808 + +[provider] +shape = "completions" + +[cost] +input = 0.275000 +output = 1.636538 +cache_read = 0.028846 + +[[cost.tiers]] +tier = { type = "context", size = 256_001 } +input = 1.092308 +output = 6.540385 +cache_read = 0.111538 diff --git a/providers/zenifra/models/zenifra/qwen3.8-27b.toml b/providers/zenifra/models/zenifra/qwen3.8-27b.toml index 535adc0fe27..9accfce891b 100644 --- a/providers/zenifra/models/zenifra/qwen3.8-27b.toml +++ b/providers/zenifra/models/zenifra/qwen3.8-27b.toml @@ -11,6 +11,9 @@ reasoning_options = [ { type = "effort", values = ["low", "medium", "xhigh"] }, ] +[modalities] +input = ["text"] + [provider] shape = "completions" diff --git a/providers/zenifra/models/zenifra/qwen3.8-max.toml b/providers/zenifra/models/zenifra/qwen3.8-max.toml index 26b996f8e1e..87b305b2092 100644 --- a/providers/zenifra/models/zenifra/qwen3.8-max.toml +++ b/providers/zenifra/models/zenifra/qwen3.8-max.toml @@ -10,6 +10,9 @@ reasoning_options = [ { type = "effort", values = ["low", "medium", "xhigh"] }, ] +[modalities] +input = ["text", "image", "video"] + [provider] shape = "completions" From 329dc4837d473405999ca6b6e46ef508a7ea9670 Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Tue, 22 Sep 2026 20:36:52 -0300 Subject: [PATCH 09/14] docs(zenifra): document thinking toggle wire field --- providers/zenifra/models/zenifra/glm-5.1.toml | 1 + providers/zenifra/models/zenifra/qwen3.6-flash.toml | 1 + providers/zenifra/models/zenifra/qwen3.6-plus.toml | 1 + 3 files changed, 3 insertions(+) diff --git a/providers/zenifra/models/zenifra/glm-5.1.toml b/providers/zenifra/models/zenifra/glm-5.1.toml index 71293d63f88..78c5779c141 100644 --- a/providers/zenifra/models/zenifra/glm-5.1.toml +++ b/providers/zenifra/models/zenifra/glm-5.1.toml @@ -1,6 +1,7 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-22). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate). # No native reasoning_effort levels advertised; expose the model's thinking toggle only. +# Toggle: enable_thinking = true|false (top-level chat/completions request). base_model = "zhipuai/glm-5.1" reasoning_options = [{ type = "toggle" }] diff --git a/providers/zenifra/models/zenifra/qwen3.6-flash.toml b/providers/zenifra/models/zenifra/qwen3.6-flash.toml index 95cc2365026..3509dfaa992 100644 --- a/providers/zenifra/models/zenifra/qwen3.6-flash.toml +++ b/providers/zenifra/models/zenifra/qwen3.6-flash.toml @@ -1,6 +1,7 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-22). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate). # No native reasoning_effort levels advertised; expose the model's thinking toggle only. +# Toggle: enable_thinking = true|false (top-level chat/completions request). base_model = "alibaba/qwen3.6-flash" reasoning_options = [{ type = "toggle" }] diff --git a/providers/zenifra/models/zenifra/qwen3.6-plus.toml b/providers/zenifra/models/zenifra/qwen3.6-plus.toml index c340d150091..40ad6a46dbf 100644 --- a/providers/zenifra/models/zenifra/qwen3.6-plus.toml +++ b/providers/zenifra/models/zenifra/qwen3.6-plus.toml @@ -1,6 +1,7 @@ # Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-22). # Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate). # No native reasoning_effort levels advertised; expose the model's thinking toggle only. +# Toggle: enable_thinking = true|false (top-level chat/completions request). base_model = "alibaba/qwen3.6-plus" reasoning_options = [{ type = "toggle" }] From e0c66536a083b97063ddbf04bf606a13f50901c9 Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sat, 26 Sep 2026 19:35:04 -0300 Subject: [PATCH 10/14] fix(zenifra): remove unavailable Qwen3.8 27B route --- packages/core/test/generate.test.ts | 6 ----- .../zenifra/models/zenifra/qwen3.8-27b.toml | 23 ------------------- 2 files changed, 29 deletions(-) delete mode 100644 providers/zenifra/models/zenifra/qwen3.8-27b.toml diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index 3d33ae3c84a..4b7fa2f106b 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -89,10 +89,6 @@ describe("catalog generation", () => { "zenifra/qwen3.6-plus": [{ type: "toggle" }], "zenifra/qwen3.7-max": [{ type: "toggle" }], "zenifra/qwen3.7-plus": [{ type: "toggle" }], - "zenifra/qwen3.8-27b": [ - { type: "toggle" }, - { type: "effort", values: ["low", "medium", "xhigh"] }, - ], "zenifra/qwen3.8-flash": [ { type: "toggle" }, { type: "effort", values: ["low", "medium", "xhigh"] }, @@ -128,7 +124,6 @@ describe("catalog generation", () => { "zenifra/qwen3.6-plus": true, "zenifra/qwen3.7-max": false, "zenifra/qwen3.7-plus": true, - "zenifra/qwen3.8-27b": false, "zenifra/qwen3.8-flash": true, "zenifra/qwen3.8-max": true, }); @@ -151,7 +146,6 @@ describe("catalog generation", () => { context: 991_808, output: 65_536, }); - expect(models["zenifra/qwen3.8-27b"]?.modalities?.input).toEqual(["text"]); expect(models["zenifra/qwen3.8-max"]?.modalities?.input).toEqual(["text", "image", "video"]); expect(models["zenifra/glm-5.1"]?.cost).toEqual({ diff --git a/providers/zenifra/models/zenifra/qwen3.8-27b.toml b/providers/zenifra/models/zenifra/qwen3.8-27b.toml deleted file mode 100644 index 9accfce891b..00000000000 --- a/providers/zenifra/models/zenifra/qwen3.8-27b.toml +++ /dev/null @@ -1,23 +0,0 @@ -# Source: GET https://ai.zenifra.com/v1/models (accessed 2026-09-12). -# Conversion: Zenifra prices are BRL; models.dev costs use BRL / 5.20 = USD (project rate, applied 2026-09-12). -# Capabilities: structured_outputs = false. -# Toggle: enable_thinking = true|false. -# Effort: reasoning_effort = low|medium|xhigh. -base_model = "alibaba/qwen3.8-27b" -attachment = false -structured_output = false -reasoning_options = [ - { type = "toggle" }, - { type = "effort", values = ["low", "medium", "xhigh"] }, -] - -[modalities] -input = ["text"] - -[provider] -shape = "completions" - -[cost] -input = 0.230769 -output = 0.865385 -cache_read = 0.057692 From e70ac5ad6d941abd0348986d80641c68ecb9b6ad Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sat, 26 Sep 2026 21:32:54 -0300 Subject: [PATCH 11/14] feat(zenifra): automate hosted catalog sync --- .github/workflows/sync-models.yml | 1 + packages/core/src/sync/index.ts | 5 +- packages/core/src/sync/providers/zenifra.ts | 288 ++++++++++++++++++ packages/core/test/generate.test.ts | 148 --------- packages/core/test/zenifra.test.ts | 192 ++++++++++++ .../zenifra/models/zenifra/qwen3.6-plus.toml | 17 +- .../zenifra/models/zenifra/qwen3.8-flash.toml | 16 +- sync.md | 11 + 8 files changed, 516 insertions(+), 162 deletions(-) create mode 100644 packages/core/src/sync/providers/zenifra.ts create mode 100644 packages/core/test/zenifra.test.ts diff --git a/.github/workflows/sync-models.yml b/.github/workflows/sync-models.yml index 3ec03eb942d..c92af550d57 100644 --- a/.github/workflows/sync-models.yml +++ b/.github/workflows/sync-models.yml @@ -89,6 +89,7 @@ jobs: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + ZENIFRA_AI_KEY: ${{ secrets.ZENIFRA_AI_KEY }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_AI_SYNC_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID }} CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_WORKERS_AI_SYNC_ACCOUNT_ID }} diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 031341b56cd..7796bd82816 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -43,6 +43,7 @@ import { vercel } from "./providers/vercel.js"; import { venice } from "./providers/venice.js"; import { wandb } from "./providers/wandb.js"; import { xai } from "./providers/xai.js"; +import { zenifra } from "./providers/zenifra.js"; const ExistingModelType = AuthoredModelShape.partial() .extend({ @@ -178,6 +179,7 @@ export const providers: { venice: SyncProvider; wandb: SyncProvider; xai: SyncProvider; + zenifra: SyncProvider; } = { aiand, ambient, @@ -217,6 +219,7 @@ export const providers: { venice, wandb, xai, + zenifra, }; export const groups = { @@ -237,7 +240,7 @@ export const groups = { "vercel", ], cloudflare: ["cloudflare-ai-gateway", "cloudflare-workers-ai"], - direct: ["aiand", "ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "fireworks-ai", "friendli", "github-copilot", "google", "hyper", "meta", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], + direct: ["aiand", "ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "fireworks-ai", "friendli", "github-copilot", "google", "hyper", "meta", "ollama-cloud", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai", "zenifra"], } as const; type ProviderID = keyof typeof providers; diff --git a/packages/core/src/sync/providers/zenifra.ts b/packages/core/src/sync/providers/zenifra.ts new file mode 100644 index 00000000000..dc495111250 --- /dev/null +++ b/packages/core/src/sync/providers/zenifra.ts @@ -0,0 +1,288 @@ +import { ReasoningOption } from "../../schema.js"; +import { MissingReasoningOptionsError } from "../missing-reasoning-options.js"; +import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; +import { factorBaseModel, resolveModelMetadataBaseModel } from "./openrouter.js"; +import { z } from "zod"; + +const API_ENDPOINT = "https://ai.zenifra.com/v1/models"; + +// Zenifra publishes its model prices in BRL per million tokens. Keep this in +// sync with the rate used by the authored catalog until the API publishes a +// currency field or the project adopts dynamic FX conversion. +const BRL_PER_USD = 5.2; + +const ZenifraPricingTier = z.object({ + min_input_tokens: z.number().int().nonnegative(), + max_input_tokens: z.number().int().positive().optional(), + input: z.number().nonnegative(), + output: z.number().nonnegative(), + cache_read_input: z.number().nonnegative().optional(), +}).passthrough(); + +const ZenifraPricing = z.object({ + input: z.number().nonnegative(), + output: z.number().nonnegative(), + cache_read_input: z.number().nonnegative().optional(), + context_tiers: z.array(ZenifraPricingTier).optional(), +}).passthrough(); + +const ZenifraReasoning = z.object({ + supported: z.boolean(), + always_on: z.boolean().optional(), + effort_levels: z.array(z.string()).optional(), +}).passthrough(); + +const ZenifraCapabilities = z.object({ + response_schema: z.boolean().optional(), + structured_outputs: z.boolean().optional(), + function_calling: z.boolean().optional(), + tool_choice: z.boolean().optional(), + reasoning: ZenifraReasoning.optional(), +}).passthrough(); + +export const ZenifraModel = z.object({ + id: z.string().min(1), + object: z.literal("model"), + owned_by: z.string().min(1), + created: z.number().int().nonnegative(), + context_length: z.number().int().positive().optional(), + max_output_tokens: z.number().int().positive().optional(), + pricing: ZenifraPricing.optional(), + capabilities: ZenifraCapabilities.optional(), + input_modalities: z.array(z.string()).optional(), + output_modalities: z.array(z.string()).optional(), + supported_operations: z.array(z.string()).optional(), + supported_parameters: z.array(z.string()).optional(), +}).passthrough(); + +export const ZenifraResponse = z.object({ + object: z.literal("list"), + data: z.array(ZenifraModel), +}).passthrough(); + +export type ZenifraModel = z.infer; + +const CANONICAL_BASE_MODEL_OVERRIDES: Record = { + "zenifra/deepseek-v4-pro": "deepseek/deepseek-v4-pro-0813", +}; + +type Modality = "text" | "audio" | "image" | "video" | "pdf"; +type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "default"; + +const REASONING_EFFORTS = new Set([ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "default", +]); + +export const zenifra = { + id: "zenifra", + name: "Zenifra", + modelsDir: "providers/zenifra/models", + deleteMissing: true, + sourceID(model: ZenifraModel) { + return model.id; + }, + missingModelID(model: ZenifraModel) { + return model.id; + }, + skippedNotice(ids: string[]) { + if (ids.length === 0) return []; + return [ + `${ids.length} Zenifra models were skipped because they could not be mapped to canonical models.dev metadata.`, + `Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`, + ]; + }, + async fetchModels() { + return fetchZenifraModels(process.env.ZENIFRA_AI_KEY); + }, + parseModels: parseZenifraModels, + translateModel(model: ZenifraModel, context) { + const existing = context.existing(model.id); + const authored = context.authored(model.id); + const baseModel = authored?.base_model + ?? existing?.base_model + ?? resolveZenifraBaseModel(model.id); + + // A new relay route without canonical metadata is not safe to author as a + // full inline model. Existing entries remain available for manual review. + if (existing === undefined && baseModel === undefined) return undefined; + + return { + id: model.id, + model: buildZenifraModel(model, existing, baseModel, authored), + }; + }, +} satisfies SyncProvider; + +export async function fetchZenifraModels( + key: string | undefined, + fetcher: typeof fetch = fetch, +) { + const token = key?.trim(); + const response = await fetcher(API_ENDPOINT, { + headers: token === undefined || token === "" + ? undefined + : { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + throw new Error(`Zenifra models request failed: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +export function parseZenifraModels(raw: unknown) { + const models = ZenifraResponse.parse(raw).data; + if (models.length === 0) { + throw new Error("Zenifra returned an empty model catalog; refusing destructive sync"); + } + return models; +} + +export function resolveZenifraBaseModel(modelID: string) { + const override = CANONICAL_BASE_MODEL_OVERRIDES[modelID]; + if (override !== undefined) return override; + + const bareID = modelID.startsWith("zenifra/") ? modelID.slice("zenifra/".length) : modelID; + return resolveModelMetadataBaseModel(bareID); +} + +export function buildZenifraModel( + model: ZenifraModel, + existing: ExistingModel | undefined, + baseModel = existing?.base_model ?? resolveZenifraBaseModel(model.id), + authored: ExistingModel | undefined = existing, +): SyncedModel { + const input = modalities(model.input_modalities, existing?.modalities?.input ?? ["text"]); + const output = modalities(model.output_modalities, existing?.modalities?.output ?? ["text"]); + const capabilities = model.capabilities; + const parameters = new Set(model.supported_parameters ?? []); + const sourceReasoning = capabilities?.reasoning?.supported; + const reasoning = sourceReasoning ?? existing?.reasoning ?? false; + const reasoningOptions = reasoning + ? resolveReasoningOptions(model, authored) + : undefined; + + if (reasoning && reasoningOptions === undefined && baseModel === undefined) { + throw new MissingReasoningOptionsError( + model.id, + "Zenifra exposes reasoning without a safe control set or authored reasoning_options", + ); + } + + const context = model.context_length ?? existing?.limit?.context ?? 0; + const outputLimit = model.max_output_tokens ?? existing?.limit?.output ?? context; + const limit = { + context, + input: existing?.limit?.input, + output: outputLimit, + }; + const values: SyncedFullModel = { + name: existing?.name ?? model.id, + description: existing?.description ?? model.id, + family: existing?.family, + release_date: existing?.release_date ?? dateFromTimestamp(model.created), + last_updated: existing?.last_updated ?? dateFromTimestamp(model.created), + attachment: input.some((value) => value !== "text"), + reasoning, + reasoning_options: reasoningOptions, + temperature: parameters.has("temperature"), + tool_call: capabilities?.function_calling + ?? capabilities?.tool_choice + ?? (parameters.has("tools") || parameters.has("tool_choice")), + structured_output: capabilities?.structured_outputs + ?? capabilities?.response_schema + ?? existing?.structured_output + ?? false, + knowledge: existing?.knowledge, + open_weights: existing?.open_weights ?? false, + status: existing?.status, + interleaved: existing?.interleaved, + provider: { shape: "completions" }, + cost: buildCost(model, existing), + limit, + modalities: { input, output }, + }; + + return baseModel === undefined + ? values + : factorBaseModel(baseModel, values, limit, authored?.base_model_omit); +} + +function resolveReasoningOptions( + model: ZenifraModel, + authored: ExistingModel | undefined, +): SyncedFullModel["reasoning_options"] { + const authoredOptions = authored?.reasoning_options?.flatMap((option) => { + const parsed = ReasoningOption.safeParse(option); + return parsed.success ? [parsed.data] : []; + }); + if (authoredOptions !== undefined && authoredOptions.length > 0) return authoredOptions; + + const levels = model.capabilities?.reasoning?.effort_levels + ?.filter((value): value is ReasoningEffort => REASONING_EFFORTS.has(value as ReasoningEffort)); + if (levels !== undefined && levels.length > 0) { + return [{ type: "effort", values: [...new Set(levels)] }]; + } + + return model.capabilities?.reasoning?.always_on === true ? [] : undefined; +} + +function buildCost( + model: ZenifraModel, + existing: ExistingModel | undefined, +): SyncedFullModel["cost"] | undefined { + const pricing = model.pricing; + if (pricing === undefined) return existing?.cost; + + const input = usd(pricing.input); + const output = usd(pricing.output); + if (input === undefined || output === undefined) return existing?.cost; + + const tiers = (pricing.context_tiers ?? []) + .filter((tier) => tier.min_input_tokens > 0) + .sort((a, b) => a.min_input_tokens - b.min_input_tokens) + .map((tier) => ({ + tier: { type: "context" as const, size: tier.min_input_tokens }, + input: usd(tier.input)!, + output: usd(tier.output)!, + cache_read: tier.cache_read_input === undefined ? undefined : usd(tier.cache_read_input), + })) + .filter((tier, index, values) => index === 0 || tier.tier.size > values[index - 1]!.tier.size); + + return { + input, + output, + reasoning: existing?.cost?.reasoning, + cache_read: pricing.cache_read_input === undefined ? undefined : usd(pricing.cache_read_input), + cache_write: existing?.cost?.cache_write, + tiers: tiers.length > 0 ? tiers : undefined, + }; +} + +function usd(value: number | undefined) { + if (value === undefined || !Number.isFinite(value) || value < 0) return undefined; + return round(value / BRL_PER_USD); +} + +function round(value: number) { + return Math.round(value * 1_000_000) / 1_000_000; +} + +function modalities(values: string[] | undefined, fallback: Modality[]): Modality[] { + const allowed = new Set(["text", "audio", "image", "video", "pdf"]); + const normalized = (values ?? []) + .map((value) => value.toLowerCase()) + .map((value) => (value === "file" ? "pdf" : value)) + .filter((value): value is Modality => allowed.has(value as Modality)); + return [...new Set(normalized.length > 0 ? normalized : fallback)]; +} + +function dateFromTimestamp(timestamp: number) { + return new Date(timestamp * 1_000).toISOString().slice(0, 10); +} diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts index 4b7fa2f106b..9ce75af62f6 100644 --- a/packages/core/test/generate.test.ts +++ b/packages/core/test/generate.test.ts @@ -45,154 +45,6 @@ describe("catalog generation", () => { }); }); - test("Zenifra model IDs preserve the upstream namespace", async () => { - const root = path.join(import.meta.dirname, "..", "..", ".."); - const providers = await generate(path.join(root, "providers")); - const modelIDs = Object.keys(providers.zenifra?.models ?? {}); - - expect(modelIDs).not.toEqual([]); - expect(modelIDs.every((modelID) => modelID.startsWith("zenifra/"))).toBe(true); - }); - - test("Zenifra exposes only native reasoning controls", async () => { - const root = path.join(import.meta.dirname, "..", "..", ".."); - const providers = await generate(path.join(root, "providers")); - const models = providers.zenifra?.models ?? {}; - - const actual = Object.fromEntries( - Object.entries(models).map(([modelID, model]) => [ - modelID, - model.reasoning_options, - ]), - ); - - expect(actual).toEqual({ - "zenifra/deepseek-v4-flash-0731": [ - { type: "toggle" }, - { type: "effort", values: ["low", "high", "max"] }, - ], - "zenifra/deepseek-v4-pro": [ - { type: "toggle" }, - { type: "effort", values: ["high", "max"] }, - ], - "zenifra/glm-5.1": [{ type: "toggle" }], - "zenifra/glm-5.2": [ - { type: "toggle" }, - { type: "effort", values: ["high", "max"] }, - ], - "zenifra/kimi-k2.5": [{ type: "toggle" }], - "zenifra/kimi-k2.7-code": [], - "zenifra/kimi-k3": [ - { type: "effort", values: ["low", "high", "max"] }, - ], - "zenifra/qwen3.6-flash": [{ type: "toggle" }], - "zenifra/qwen3.6-plus": [{ type: "toggle" }], - "zenifra/qwen3.7-max": [{ type: "toggle" }], - "zenifra/qwen3.7-plus": [{ type: "toggle" }], - "zenifra/qwen3.8-flash": [ - { type: "toggle" }, - { type: "effort", values: ["low", "medium", "xhigh"] }, - ], - "zenifra/qwen3.8-max": [ - { type: "toggle" }, - { type: "effort", values: ["low", "medium", "xhigh"] }, - ], - }); - }); - - test("Zenifra attachment support matches its served input modalities", async () => { - const root = path.join(import.meta.dirname, "..", "..", ".."); - const providers = await generate(path.join(root, "providers")); - const models = providers.zenifra?.models ?? {}; - - const actual = Object.fromEntries( - Object.entries(models).map(([modelID, model]) => [ - modelID, - model.attachment, - ]), - ); - - expect(actual).toEqual({ - "zenifra/deepseek-v4-flash-0731": false, - "zenifra/deepseek-v4-pro": false, - "zenifra/glm-5.1": false, - "zenifra/glm-5.2": false, - "zenifra/kimi-k2.5": true, - "zenifra/kimi-k2.7-code": true, - "zenifra/kimi-k3": true, - "zenifra/qwen3.6-flash": true, - "zenifra/qwen3.6-plus": true, - "zenifra/qwen3.7-max": false, - "zenifra/qwen3.7-plus": true, - "zenifra/qwen3.8-flash": true, - "zenifra/qwen3.8-max": true, - }); - }); - - test("Zenifra serves current limits, modalities, and converted tier prices", async () => { - const root = path.join(import.meta.dirname, "..", "..", ".."); - const providers = await generate(path.join(root, "providers")); - const models = providers.zenifra?.models ?? {}; - - expect(models["zenifra/glm-5.1"]?.limit).toEqual({ - context: 202_745, - output: 131_072, - }); - expect(models["zenifra/qwen3.6-flash"]?.limit).toEqual({ - context: 991_808, - output: 65_536, - }); - expect(models["zenifra/qwen3.6-plus"]?.limit).toEqual({ - context: 991_808, - output: 65_536, - }); - expect(models["zenifra/qwen3.8-max"]?.modalities?.input).toEqual(["text", "image", "video"]); - - expect(models["zenifra/glm-5.1"]?.cost).toEqual({ - input: 1.144231, - output: 4.578846, - cache_read: 0.228846, - tiers: [{ - tier: { type: "context", size: 32_001 }, - input: 1.526923, - output: 5.340385, - cache_read: 0.305769, - }], - }); - expect(models["zenifra/qwen3.6-flash"]?.cost).toEqual({ - input: 0.163462, - output: 0.980769, - cache_read: 0.017308, - context_over_200k: { - input: 0.653846, - output: 3.923077, - cache_read: 0.065385, - }, - tiers: [{ - tier: { type: "context", size: 256_001 }, - input: 0.653846, - output: 3.923077, - cache_read: 0.065385, - }], - }); - expect(models["zenifra/qwen3.6-plus"]?.cost).toEqual({ - input: 0.275, - output: 1.636538, - cache_read: 0.028846, - context_over_200k: { - input: 1.092308, - output: 6.540385, - cache_read: 0.111538, - }, - tiers: [{ - tier: { type: "context", size: 256_001 }, - input: 1.092308, - output: 6.540385, - cache_read: 0.111538, - }], - }); - }); - test("base_model can factor metadata without changing provider JSON", async () => { await withFixture(async (root) => { await write(root, "providers/direct/provider.toml", providerToml("Direct")); diff --git a/packages/core/test/zenifra.test.ts b/packages/core/test/zenifra.test.ts new file mode 100644 index 00000000000..4103a51a27a --- /dev/null +++ b/packages/core/test/zenifra.test.ts @@ -0,0 +1,192 @@ +import { expect, test } from "bun:test"; + +import type { ExistingModel } from "../src/sync/index.js"; +import { MissingReasoningOptionsError } from "../src/sync/missing-reasoning-options.js"; +import { + buildZenifraModel, + fetchZenifraModels, + parseZenifraModels, + resolveZenifraBaseModel, + zenifra, + type ZenifraModel, +} from "../src/sync/providers/zenifra.js"; + +function zenifraModel(overrides: Partial = {}): ZenifraModel { + return { + id: "zenifra/qwen3.8-flash", + object: "model", + owned_by: "zenifra", + created: 1_677_610_602, + context_length: 1_000_000, + max_output_tokens: 131_072, + pricing: { + input: 0.6, + output: 2, + cache_read_input: 0.1, + unit: "per_million_tokens", + context_tiers: [ + { + min_input_tokens: 0, + max_input_tokens: 256_000, + input: 0.6, + output: 2, + cache_read_input: 0.1, + }, + { + min_input_tokens: 256_001, + max_input_tokens: 1_000_000, + input: 3.4, + output: 20.4, + cache_read_input: 0.34, + }, + ], + }, + capabilities: { + system_messages: true, + response_schema: true, + vision: true, + function_calling: true, + tool_choice: true, + structured_outputs: true, + reasoning: { + supported: true, + always_on: false, + effort_levels: ["low", "medium", "xhigh"], + }, + }, + input_modalities: ["text", "image", "video"], + output_modalities: ["text"], + supported_operations: ["/v1/chat/completions", "/v1/responses"], + supported_parameters: [ + "max_tokens", + "temperature", + "tools", + "tool_choice", + ], + ...overrides, + }; +} + +function existingModel(overrides: ExistingModel = {}): ExistingModel { + return { + base_model: "alibaba/qwen3.8-flash", + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + ], + cost: { + input: 1, + output: 2, + cache_read: 0.1, + tiers: [{ tier: { type: "context", size: 256_001 }, input: 2, output: 4 }], + }, + limit: { context: 100_000, output: 8_000 }, + ...overrides, + }; +} + +test("fetches the public Zenifra catalog without authentication", async () => { + let request: Request | undefined; + const fetcher = (async (input: string | URL | Request, init?: RequestInit) => { + request = input instanceof Request + ? new Request(input, init) + : new Request(input.toString(), init); + return Response.json({ object: "list", data: [zenifraModel()] }); + }) as unknown as typeof fetch; + + const raw = await fetchZenifraModels(undefined, fetcher); + + expect(request?.url).toBe("https://ai.zenifra.com/v1/models"); + expect(request?.headers.get("authorization")).toBeNull(); + expect(raw.data).toHaveLength(1); +}); + +test("adds optional bearer authentication when a Zenifra key is configured", async () => { + let request: Request | undefined; + const fetcher = (async (input: string | URL | Request, init?: RequestInit) => { + request = input instanceof Request + ? new Request(input, init) + : new Request(input.toString(), init); + return Response.json({ object: "list", data: [zenifraModel()] }); + }) as unknown as typeof fetch; + + await fetchZenifraModels("test-key", fetcher); + + expect(request?.headers.get("authorization")).toBe("Bearer test-key"); +}); + +test("rejects an empty Zenifra catalog before destructive sync", () => { + expect(() => parseZenifraModels({ object: "list", data: [] })).toThrow( + "Zenifra returned an empty model catalog; refusing destructive sync", + ); +}); + +test("maps Zenifra prices, tiers, limits, capabilities, and modalities", () => { + const result = buildZenifraModel( + zenifraModel({ id: "zenifra/test" }), + existingModel({ base_model: undefined }), + ); + + expect(result).toMatchObject({ + attachment: true, + reasoning: true, + reasoning_options: [ + { type: "toggle" }, + { type: "effort", values: ["low", "medium", "xhigh"] }, + ], + temperature: true, + tool_call: true, + structured_output: true, + provider: { shape: "completions" }, + limit: { context: 1_000_000, output: 131_072 }, + modalities: { input: ["text", "image", "video"], output: ["text"] }, + cost: { + input: 0.115385, + output: 0.384615, + cache_read: 0.019231, + tiers: [{ + tier: { type: "context", size: 256_001 }, + input: 0.653846, + output: 3.923077, + cache_read: 0.065385, + }], + }, + }); +}); + +test("derives temperature support from the API parameters", () => { + const result = buildZenifraModel( + zenifraModel({ supported_parameters: ["temperature"] }), + existingModel({ temperature: false }), + ); + + expect(result.temperature).toBe(true); +}); + +test("resolves Zenifra routes to canonical model metadata", () => { + expect(resolveZenifraBaseModel("zenifra/qwen3.8-flash")).toBe("alibaba/qwen3.8-flash"); + expect(resolveZenifraBaseModel("zenifra/deepseek-v4-pro")).toBe("deepseek/deepseek-v4-pro-0813"); +}); + +test("reports new Zenifra routes without canonical metadata", () => { + const model = zenifraModel({ id: "zenifra/new-model" }); + + expect(zenifra.translateModel(model, { + existing: () => undefined, + authored: () => undefined, + })).toBeUndefined(); + expect(zenifra.missingModelID?.(model)).toBe("zenifra/new-model"); +}); + +test("requires authored controls for an inline reasoner with no safe feed controls", () => { + const model = zenifraModel({ + id: "zenifra/inline-reasoner", + capabilities: { reasoning: { supported: true } }, + }); + + expect(() => buildZenifraModel( + model, + existingModel({ base_model: undefined, reasoning_options: undefined }), + )).toThrow(MissingReasoningOptionsError); +}); diff --git a/providers/zenifra/models/zenifra/qwen3.6-plus.toml b/providers/zenifra/models/zenifra/qwen3.6-plus.toml index 40ad6a46dbf..4d2411a22ba 100644 --- a/providers/zenifra/models/zenifra/qwen3.6-plus.toml +++ b/providers/zenifra/models/zenifra/qwen3.6-plus.toml @@ -3,16 +3,13 @@ # No native reasoning_effort levels advertised; expose the model's thinking toggle only. # Toggle: enable_thinking = true|false (top-level chat/completions request). base_model = "alibaba/qwen3.6-plus" -reasoning_options = [{ type = "toggle" }] +structured_output = true -[limit] -context = 991_808 - -[provider] -shape = "completions" +[[reasoning_options]] +type = "toggle" [cost] -input = 0.275000 +input = 0.275 output = 1.636538 cache_read = 0.028846 @@ -21,3 +18,9 @@ tier = { type = "context", size = 256_001 } input = 1.092308 output = 6.540385 cache_read = 0.111538 + +[limit] +context = 991_808 + +[provider] +shape = "completions" diff --git a/providers/zenifra/models/zenifra/qwen3.8-flash.toml b/providers/zenifra/models/zenifra/qwen3.8-flash.toml index 5b366cff38b..61d91a9d9b1 100644 --- a/providers/zenifra/models/zenifra/qwen3.8-flash.toml +++ b/providers/zenifra/models/zenifra/qwen3.8-flash.toml @@ -4,15 +4,19 @@ # Toggle: enable_thinking = true|false. # Effort: reasoning_effort = low|medium|xhigh. base_model = "alibaba/qwen3.8-flash" -reasoning_options = [ - { type = "toggle" }, - { type = "effort", values = ["low", "medium", "xhigh"] }, -] +temperature = true -[provider] -shape = "completions" +[[reasoning_options]] +type = "toggle" + +[[reasoning_options]] +type = "effort" +values = ["low", "medium", "xhigh"] [cost] input = 0.115385 output = 0.384615 cache_read = 0.019231 + +[provider] +shape = "completions" diff --git a/sync.md b/sync.md index f2bb9a5130e..f4a7240cbe6 100644 --- a/sync.md +++ b/sync.md @@ -24,6 +24,7 @@ The grouped sync targets are available for local convenience, but CI syncs each - `bun models:sync ollama-cloud` syncs Ollama Cloud catalog availability. - `bun models:sync github-copilot` syncs only GitHub Copilot pricing. - `bun models:sync tinfoil` syncs only Tinfoil. +- `bun models:sync zenifra` syncs only Zenifra. - `bun models:sync aggregators --dry-run` prints changes without writing model files. - `bun models:sync aggregators --new-only` creates new model files but skips updates and removals. - `bun models:sync --open-issues` opens GitHub issues for missing models (on by default only when `GITHUB_ACTIONS=true`). @@ -268,6 +269,16 @@ xAI is implemented in `packages/core/src/sync/providers/xai.ts`. - New token-priced chat, safety, and embedding models are not created automatically (`skipCreates`); each missing ID opens a deduped GitHub issue for hand-authored metadata. - Per-request tool, TTS, transcription, realtime, and document-processing services are ignored because their pricing cannot be represented by the token-cost schema. +## Zenifra Notes + +- Zenifra is implemented in `packages/core/src/sync/providers/zenifra.ts`. +- Source endpoint: `https://ai.zenifra.com/v1/models`; no authentication is required for the public catalog. `ZENIFRA_AI_KEY` is accepted locally or when the endpoint is configured to require a key. +- The endpoint's prices are BRL per million tokens and are converted to USD using the `5.20 BRL/USD` rate used by the authored catalog. Off-peak prices are not represented because the catalog cost schema has no time-of-day dimension. +- Context tiers, context/output limits, modalities, attachment support, tool calling, structured output, and temperature support come from the endpoint when published. +- Existing authored reasoning options are preserved when the endpoint does not provide an unambiguous wire field. New reasoning models without safe controls open a deduplicated missing-model issue instead of receiving an invented empty option set. +- Model IDs are mapped to canonical `models/` metadata; unknown remote IDs are reported for manual authoring. Zenifra models absent from a successful non-empty response are removed from the provider catalog. +- An empty response is rejected before deletion so a transient or truncated feed cannot erase the local catalog. + ## OpenAI Notes - OpenAI is implemented in `packages/core/src/sync/providers/openai.ts`. From 40cd9ac842ae181b0e68300e7096fd5ab36e050f Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sat, 26 Sep 2026 21:46:07 -0300 Subject: [PATCH 12/14] fix(zenifra): make sync fail safe --- packages/core/src/sync/providers/zenifra.ts | 90 ++++++++++++++------- packages/core/test/zenifra.test.ts | 40 ++++++++- sync.md | 4 +- 3 files changed, 101 insertions(+), 33 deletions(-) diff --git a/packages/core/src/sync/providers/zenifra.ts b/packages/core/src/sync/providers/zenifra.ts index dc495111250..9418d082191 100644 --- a/packages/core/src/sync/providers/zenifra.ts +++ b/packages/core/src/sync/providers/zenifra.ts @@ -1,8 +1,9 @@ +import { z } from "zod"; + import { ReasoningOption } from "../../schema.js"; import { MissingReasoningOptionsError } from "../missing-reasoning-options.js"; import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; -import { factorBaseModel, resolveModelMetadataBaseModel } from "./openrouter.js"; -import { z } from "zod"; +import { factorBaseModel, modelMetadata, resolveModelMetadataBaseModel } from "./openrouter.js"; const API_ENDPOINT = "https://ai.zenifra.com/v1/models"; @@ -84,7 +85,10 @@ export const zenifra = { id: "zenifra", name: "Zenifra", modelsDir: "providers/zenifra/models", - deleteMissing: true, + // A valid but partial public feed must not erase the local catalog. Missing + // routes are retained for manual lifecycle review until Zenifra publishes a + // deletion-specific signal. + deleteMissing: false, sourceID(model: ZenifraModel) { return model.id; }, @@ -98,6 +102,13 @@ export const zenifra = { `Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`, ]; }, + missingNotice(paths: string[]) { + if (paths.length === 0) return []; + return [ + `${paths.length} local Zenifra models were absent from the live API and were retained for manual lifecycle review.`, + `Retained local paths: ${paths.map((path) => `\`${path}\``).join(", ")}`, + ]; + }, async fetchModels() { return fetchZenifraModels(process.env.ZENIFRA_AI_KEY); }, @@ -139,7 +150,7 @@ export async function fetchZenifraModels( export function parseZenifraModels(raw: unknown) { const models = ZenifraResponse.parse(raw).data; if (models.length === 0) { - throw new Error("Zenifra returned an empty model catalog; refusing destructive sync"); + throw new Error("Zenifra returned an empty model catalog; refusing to sync"); } return models; } @@ -161,14 +172,16 @@ export function buildZenifraModel( const input = modalities(model.input_modalities, existing?.modalities?.input ?? ["text"]); const output = modalities(model.output_modalities, existing?.modalities?.output ?? ["text"]); const capabilities = model.capabilities; - const parameters = new Set(model.supported_parameters ?? []); + const parameters = model.supported_parameters === undefined + ? undefined + : new Set(model.supported_parameters); const sourceReasoning = capabilities?.reasoning?.supported; const reasoning = sourceReasoning ?? existing?.reasoning ?? false; const reasoningOptions = reasoning ? resolveReasoningOptions(model, authored) : undefined; - if (reasoning && reasoningOptions === undefined && baseModel === undefined) { + if (reasoning && reasoningOptions === undefined && !baseHasReasoningOptions(baseModel)) { throw new MissingReasoningOptionsError( model.id, "Zenifra exposes reasoning without a safe control set or authored reasoning_options", @@ -182,25 +195,18 @@ export function buildZenifraModel( input: existing?.limit?.input, output: outputLimit, }; - const values: SyncedFullModel = { - name: existing?.name ?? model.id, - description: existing?.description ?? model.id, - family: existing?.family, - release_date: existing?.release_date ?? dateFromTimestamp(model.created), - last_updated: existing?.last_updated ?? dateFromTimestamp(model.created), + const hostValues = { attachment: input.some((value) => value !== "text"), reasoning, reasoning_options: reasoningOptions, - temperature: parameters.has("temperature"), + temperature: parameters === undefined ? existing?.temperature : parameters.has("temperature"), tool_call: capabilities?.function_calling - ?? capabilities?.tool_choice - ?? (parameters.has("tools") || parameters.has("tool_choice")), + ?? (parameters === undefined + ? existing?.tool_call + : parameters.has("tools") || parameters.has("tool_choice")), structured_output: capabilities?.structured_outputs ?? capabilities?.response_schema - ?? existing?.structured_output - ?? false, - knowledge: existing?.knowledge, - open_weights: existing?.open_weights ?? false, + ?? (parameters === undefined ? existing?.structured_output : parameters.has("structured_outputs")), status: existing?.status, interleaved: existing?.interleaved, provider: { shape: "completions" }, @@ -209,28 +215,52 @@ export function buildZenifraModel( modalities: { input, output }, }; - return baseModel === undefined - ? values - : factorBaseModel(baseModel, values, limit, authored?.base_model_omit); + if (baseModel !== undefined) { + return factorBaseModel(baseModel, hostValues, limit, authored?.base_model_omit); + } + + return { + name: existing?.name ?? model.id, + description: existing?.description ?? model.id, + family: existing?.family, + release_date: existing?.release_date ?? dateFromTimestamp(model.created), + last_updated: existing?.last_updated ?? dateFromTimestamp(model.created), + knowledge: existing?.knowledge, + open_weights: existing?.open_weights ?? false, + ...hostValues, + } satisfies SyncedFullModel; } function resolveReasoningOptions( model: ZenifraModel, authored: ExistingModel | undefined, ): SyncedFullModel["reasoning_options"] { - const authoredOptions = authored?.reasoning_options?.flatMap((option) => { - const parsed = ReasoningOption.safeParse(option); - return parsed.success ? [parsed.data] : []; - }); - if (authoredOptions !== undefined && authoredOptions.length > 0) return authoredOptions; + if (authored?.reasoning_options !== undefined) { + return authored.reasoning_options.flatMap((option) => { + const parsed = ReasoningOption.safeParse(option); + return parsed.success ? [parsed.data] : []; + }); + } - const levels = model.capabilities?.reasoning?.effort_levels - ?.filter((value): value is ReasoningEffort => REASONING_EFFORTS.has(value as ReasoningEffort)); + const levels = model.supported_parameters?.includes("reasoning_effort") + ? model.capabilities?.reasoning?.effort_levels + ?.filter((value): value is ReasoningEffort => REASONING_EFFORTS.has(value as ReasoningEffort)) + : undefined; if (levels !== undefined && levels.length > 0) { return [{ type: "effort", values: [...new Set(levels)] }]; } - return model.capabilities?.reasoning?.always_on === true ? [] : undefined; + if (model.capabilities?.reasoning?.always_on === true) return []; + return undefined; +} + +function baseHasReasoningOptions(baseModel: string | undefined) { + if (baseModel === undefined) return false; + try { + return Array.isArray(modelMetadata(baseModel).reasoning_options); + } catch { + return false; + } } function buildCost( diff --git a/packages/core/test/zenifra.test.ts b/packages/core/test/zenifra.test.ts index 4103a51a27a..7310f9dd5f6 100644 --- a/packages/core/test/zenifra.test.ts +++ b/packages/core/test/zenifra.test.ts @@ -118,7 +118,7 @@ test("adds optional bearer authentication when a Zenifra key is configured", asy test("rejects an empty Zenifra catalog before destructive sync", () => { expect(() => parseZenifraModels({ object: "list", data: [] })).toThrow( - "Zenifra returned an empty model catalog; refusing destructive sync", + "Zenifra returned an empty model catalog; refusing to sync", ); }); @@ -190,3 +190,41 @@ test("requires authored controls for an inline reasoner with no safe feed contro existingModel({ base_model: undefined, reasoning_options: undefined }), )).toThrow(MissingReasoningOptionsError); }); + +test("preserves authored capabilities when optional feed fields are absent", () => { + const result = buildZenifraModel( + zenifraModel({ + id: "zenifra/inline-model", + capabilities: undefined, + input_modalities: undefined, + output_modalities: undefined, + supported_parameters: undefined, + }), + existingModel({ + base_model: undefined, + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, + temperature: true, + tool_call: true, + structured_output: true, + reasoning_options: [], + }), + ); + + expect(result).toMatchObject({ + attachment: true, + modalities: { input: ["text", "image"], output: ["text"] }, + temperature: true, + tool_call: true, + structured_output: true, + reasoning_options: [], + }); +}); + +test("retains missing Zenifra routes instead of deleting on feed omission", () => { + expect(zenifra.deleteMissing).toBe(false); + expect(zenifra.missingNotice?.(["kimi-k3.toml"])).toEqual([ + "1 local Zenifra models were absent from the live API and were retained for manual lifecycle review.", + "Retained local paths: `kimi-k3.toml`", + ]); +}); diff --git a/sync.md b/sync.md index f4a7240cbe6..c2c5dd040ee 100644 --- a/sync.md +++ b/sync.md @@ -276,8 +276,8 @@ xAI is implemented in `packages/core/src/sync/providers/xai.ts`. - The endpoint's prices are BRL per million tokens and are converted to USD using the `5.20 BRL/USD` rate used by the authored catalog. Off-peak prices are not represented because the catalog cost schema has no time-of-day dimension. - Context tiers, context/output limits, modalities, attachment support, tool calling, structured output, and temperature support come from the endpoint when published. - Existing authored reasoning options are preserved when the endpoint does not provide an unambiguous wire field. New reasoning models without safe controls open a deduplicated missing-model issue instead of receiving an invented empty option set. -- Model IDs are mapped to canonical `models/` metadata; unknown remote IDs are reported for manual authoring. Zenifra models absent from a successful non-empty response are removed from the provider catalog. -- An empty response is rejected before deletion so a transient or truncated feed cannot erase the local catalog. +- Model IDs are mapped to canonical `models/` metadata; unknown remote IDs are reported for manual authoring. Zenifra models absent from a successful response are retained for manual lifecycle review because the public feed has no deletion signal. +- An empty response is rejected before syncing so a transient or truncated feed cannot change the local catalog. ## OpenAI Notes From 7bf577e2065fc636eaea60aa665a3c85ff4d5b1f Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sat, 26 Sep 2026 22:22:25 -0300 Subject: [PATCH 13/14] fix(zenifra): preserve partial feed metadata --- packages/core/src/sync/providers/zenifra.ts | 66 ++++++++++++++---- packages/core/test/zenifra.test.ts | 76 ++++++++++++++++++++- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/packages/core/src/sync/providers/zenifra.ts b/packages/core/src/sync/providers/zenifra.ts index 9418d082191..d887821f97a 100644 --- a/packages/core/src/sync/providers/zenifra.ts +++ b/packages/core/src/sync/providers/zenifra.ts @@ -122,7 +122,14 @@ export const zenifra = { // A new relay route without canonical metadata is not safe to author as a // full inline model. Existing entries remain available for manual review. - if (existing === undefined && baseModel === undefined) return undefined; + if ( + existing === undefined + && ( + baseModel === undefined + || model.pricing?.input === undefined + || model.pricing.output === undefined + ) + ) return undefined; return { id: model.id, @@ -169,34 +176,45 @@ export function buildZenifraModel( baseModel = existing?.base_model ?? resolveZenifraBaseModel(model.id), authored: ExistingModel | undefined = existing, ): SyncedModel { - const input = modalities(model.input_modalities, existing?.modalities?.input ?? ["text"]); - const output = modalities(model.output_modalities, existing?.modalities?.output ?? ["text"]); + const input = model.input_modalities === undefined + ? existing?.modalities?.input + : modalities(model.input_modalities, ["text"]); + const output = model.output_modalities === undefined + ? existing?.modalities?.output + : modalities(model.output_modalities, ["text"]); const capabilities = model.capabilities; const parameters = model.supported_parameters === undefined ? undefined : new Set(model.supported_parameters); const sourceReasoning = capabilities?.reasoning?.supported; - const reasoning = sourceReasoning ?? existing?.reasoning ?? false; - const reasoningOptions = reasoning + const reasoning = sourceReasoning + ?? existing?.reasoning + ?? (baseModel === undefined ? false : undefined); + const reasoningOptions = reasoning === true ? resolveReasoningOptions(model, authored) : undefined; - if (reasoning && reasoningOptions === undefined && !baseHasReasoningOptions(baseModel)) { + if (reasoning === true && reasoningOptions === undefined && !baseHasReasoningOptions(baseModel)) { throw new MissingReasoningOptionsError( model.id, "Zenifra exposes reasoning without a safe control set or authored reasoning_options", ); } - const context = model.context_length ?? existing?.limit?.context ?? 0; - const outputLimit = model.max_output_tokens ?? existing?.limit?.output ?? context; + const context = model.context_length + ?? existing?.limit?.context + ?? (baseModel === undefined ? 0 : undefined); + const outputLimit = model.max_output_tokens + ?? existing?.limit?.output + ?? context + ?? (baseModel === undefined ? 0 : undefined); const limit = { context, input: existing?.limit?.input, output: outputLimit, }; const hostValues = { - attachment: input.some((value) => value !== "text"), + attachment: input === undefined ? existing?.attachment : input.some((value) => value !== "text"), reasoning, reasoning_options: reasoningOptions, temperature: parameters === undefined ? existing?.temperature : parameters.has("temperature"), @@ -210,7 +228,7 @@ export function buildZenifraModel( status: existing?.status, interleaved: existing?.interleaved, provider: { shape: "completions" }, - cost: buildCost(model, existing), + cost: buildCost(model, existing, reasoning), limit, modalities: { input, output }, }; @@ -228,6 +246,19 @@ export function buildZenifraModel( knowledge: existing?.knowledge, open_weights: existing?.open_weights ?? false, ...hostValues, + attachment: hostValues.attachment ?? existing?.attachment ?? false, + reasoning: hostValues.reasoning ?? false, + tool_call: hostValues.tool_call ?? existing?.tool_call ?? false, + structured_output: hostValues.structured_output ?? existing?.structured_output ?? false, + limit: { + ...limit, + context: context ?? 0, + output: outputLimit ?? context ?? 0, + }, + modalities: { + input: input ?? ["text"], + output: output ?? ["text"], + }, } satisfies SyncedFullModel; } @@ -266,6 +297,7 @@ function baseHasReasoningOptions(baseModel: string | undefined) { function buildCost( model: ZenifraModel, existing: ExistingModel | undefined, + reasoning: boolean | undefined, ): SyncedFullModel["cost"] | undefined { const pricing = model.pricing; if (pricing === undefined) return existing?.cost; @@ -288,10 +320,18 @@ function buildCost( return { input, output, - reasoning: existing?.cost?.reasoning, - cache_read: pricing.cache_read_input === undefined ? undefined : usd(pricing.cache_read_input), + reasoning: reasoning === false ? undefined : existing?.cost?.reasoning, + cache_read: pricing.cache_read_input === undefined + ? existing?.cost?.cache_read + : usd(pricing.cache_read_input), cache_write: existing?.cost?.cache_write, - tiers: tiers.length > 0 ? tiers : undefined, + input_audio: existing?.cost?.input_audio, + output_audio: existing?.cost?.output_audio, + tiers: pricing.context_tiers === undefined + ? existing?.cost?.tiers + : tiers.length > 0 + ? tiers + : undefined, }; } diff --git a/packages/core/test/zenifra.test.ts b/packages/core/test/zenifra.test.ts index 7310f9dd5f6..63a3dc6a922 100644 --- a/packages/core/test/zenifra.test.ts +++ b/packages/core/test/zenifra.test.ts @@ -182,7 +182,13 @@ test("reports new Zenifra routes without canonical metadata", () => { test("requires authored controls for an inline reasoner with no safe feed controls", () => { const model = zenifraModel({ id: "zenifra/inline-reasoner", - capabilities: { reasoning: { supported: true } }, + capabilities: { + reasoning: { + supported: true, + effort_levels: ["low", "medium", "xhigh"], + }, + }, + supported_parameters: ["temperature"], }); expect(() => buildZenifraModel( @@ -221,6 +227,74 @@ test("preserves authored capabilities when optional feed fields are absent", () }); }); +test("does not stamp defaults when a new canonical route omits optional fields", () => { + const result = buildZenifraModel(zenifraModel({ + input_modalities: undefined, + output_modalities: undefined, + context_length: undefined, + max_output_tokens: undefined, + capabilities: undefined, + supported_parameters: undefined, + }), undefined); + + expect(result).toMatchObject({ base_model: "alibaba/qwen3.8-flash" }); + expect(result).not.toHaveProperty("attachment"); + expect(result).not.toHaveProperty("limit"); + expect(result).not.toHaveProperty("modalities"); + expect(result).not.toHaveProperty("tool_call"); + expect(result).not.toHaveProperty("temperature"); +}); + +test("preserves authored pricing fields omitted by a partial feed", () => { + const result = buildZenifraModel( + zenifraModel({ + id: "zenifra/partial-pricing", + pricing: { input: 0.6, output: 2 }, + }), + existingModel({ + base_model: undefined, + reasoning_options: [], + cost: { + input: 1, + output: 2, + cache_read: 0.5, + cache_write: 0.2, + input_audio: 3, + output_audio: 4, + tiers: [{ tier: { type: "context", size: 256_001 }, input: 2, output: 4 }], + }, + }), + ); + + expect(result.cost).toMatchObject({ + input: 0.115385, + output: 0.384615, + cache_read: 0.5, + cache_write: 0.2, + input_audio: 3, + output_audio: 4, + tiers: [{ tier: { type: "context", size: 256_001 }, input: 2, output: 4 }], + }); +}); + +test("clears reasoning cost when the feed disables reasoning", () => { + const result = buildZenifraModel( + zenifraModel({ + id: "zenifra/non-reasoning", + capabilities: { reasoning: { supported: false } }, + }), + existingModel({ + base_model: undefined, + reasoning: true, + reasoning_options: undefined, + cost: { input: 1, output: 2, reasoning: 0.5 }, + }), + ); + + expect(result.reasoning).toBe(false); + expect(result.cost?.reasoning).toBeUndefined(); +}); + test("retains missing Zenifra routes instead of deleting on feed omission", () => { expect(zenifra.deleteMissing).toBe(false); expect(zenifra.missingNotice?.(["kimi-k3.toml"])).toEqual([ From 80ec9a32ff99aa90395cc58c758d81966c96e25c Mon Sep 17 00:00:00 2001 From: ramonpaolo Date: Sat, 26 Sep 2026 22:39:49 -0300 Subject: [PATCH 14/14] fix(zenifra): fail closed on incomplete catalogs --- packages/core/src/sync/providers/zenifra.ts | 28 ++++----- packages/core/test/zenifra.test.ts | 63 ++++++++++++++++++++- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/packages/core/src/sync/providers/zenifra.ts b/packages/core/src/sync/providers/zenifra.ts index d887821f97a..be74a04c3d3 100644 --- a/packages/core/src/sync/providers/zenifra.ts +++ b/packages/core/src/sync/providers/zenifra.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { ReasoningOption } from "../../schema.js"; import { MissingReasoningOptionsError } from "../missing-reasoning-options.js"; import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; -import { factorBaseModel, modelMetadata, resolveModelMetadataBaseModel } from "./openrouter.js"; +import { factorBaseModel, resolveModelMetadataBaseModel } from "./openrouter.js"; const API_ENDPOINT = "https://ai.zenifra.com/v1/models"; @@ -128,6 +128,7 @@ export const zenifra = { baseModel === undefined || model.pricing?.input === undefined || model.pricing.output === undefined + || model.capabilities?.reasoning === undefined ) ) return undefined; @@ -194,7 +195,7 @@ export function buildZenifraModel( ? resolveReasoningOptions(model, authored) : undefined; - if (reasoning === true && reasoningOptions === undefined && !baseHasReasoningOptions(baseModel)) { + if (reasoning === true && reasoningOptions === undefined && (existing === undefined || baseModel === undefined)) { throw new MissingReasoningOptionsError( model.id, "Zenifra exposes reasoning without a safe control set or authored reasoning_options", @@ -206,7 +207,6 @@ export function buildZenifraModel( ?? (baseModel === undefined ? 0 : undefined); const outputLimit = model.max_output_tokens ?? existing?.limit?.output - ?? context ?? (baseModel === undefined ? 0 : undefined); const limit = { context, @@ -285,26 +285,17 @@ function resolveReasoningOptions( return undefined; } -function baseHasReasoningOptions(baseModel: string | undefined) { - if (baseModel === undefined) return false; - try { - return Array.isArray(modelMetadata(baseModel).reasoning_options); - } catch { - return false; - } -} - function buildCost( model: ZenifraModel, existing: ExistingModel | undefined, reasoning: boolean | undefined, ): SyncedFullModel["cost"] | undefined { const pricing = model.pricing; - if (pricing === undefined) return existing?.cost; + if (pricing === undefined) return clearReasoningCost(existing?.cost, reasoning); const input = usd(pricing.input); const output = usd(pricing.output); - if (input === undefined || output === undefined) return existing?.cost; + if (input === undefined || output === undefined) return clearReasoningCost(existing?.cost, reasoning); const tiers = (pricing.context_tiers ?? []) .filter((tier) => tier.min_input_tokens > 0) @@ -335,6 +326,15 @@ function buildCost( }; } +function clearReasoningCost( + cost: SyncedFullModel["cost"] | undefined, + reasoning: boolean | undefined, +) { + if (cost === undefined || reasoning !== false) return cost; + const { reasoning: _reasoning, ...withoutReasoning } = cost; + return withoutReasoning; +} + function usd(value: number | undefined) { if (value === undefined || !Number.isFinite(value) || value < 0) return undefined; return round(value / BRL_PER_USD); diff --git a/packages/core/test/zenifra.test.ts b/packages/core/test/zenifra.test.ts index 63a3dc6a922..297c6887c85 100644 --- a/packages/core/test/zenifra.test.ts +++ b/packages/core/test/zenifra.test.ts @@ -1,6 +1,8 @@ import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; -import type { ExistingModel } from "../src/sync/index.js"; +import { syncProvider, type ExistingModel } from "../src/sync/index.js"; import { MissingReasoningOptionsError } from "../src/sync/missing-reasoning-options.js"; import { buildZenifraModel, @@ -179,6 +181,15 @@ test("reports new Zenifra routes without canonical metadata", () => { expect(zenifra.missingModelID?.(model)).toBe("zenifra/new-model"); }); +test("skips a new canonical route when reasoning metadata is omitted", () => { + const model = zenifraModel({ capabilities: undefined }); + + expect(zenifra.translateModel(model, { + existing: () => undefined, + authored: () => undefined, + })).toBeUndefined(); +}); + test("requires authored controls for an inline reasoner with no safe feed controls", () => { const model = zenifraModel({ id: "zenifra/inline-reasoner", @@ -282,6 +293,7 @@ test("clears reasoning cost when the feed disables reasoning", () => { zenifraModel({ id: "zenifra/non-reasoning", capabilities: { reasoning: { supported: false } }, + pricing: undefined, }), existingModel({ base_model: undefined, @@ -302,3 +314,52 @@ test("retains missing Zenifra routes instead of deleting on feed omission", () = "Retained local paths: `kimi-k3.toml`", ]); }); + +test("sync runner retains local files absent from the Zenifra feed", async () => { + const root = await mkdtemp(path.join(import.meta.dirname, "zenifra-sync-")); + const modelsDir = path.join(root, "models"); + await mkdir(modelsDir, { recursive: true }); + await Bun.write(path.join(modelsDir, "legacy.toml"), `name = "Legacy" +description = "Legacy model retained for lifecycle review" +attachment = false +reasoning = false +tool_call = false +open_weights = false +release_date = "2026-01-01" +last_updated = "2026-01-01" + +[limit] +context = 1000 +output = 100 + +[modalities] +input = ["text"] +output = ["text"] + +[cost] +input = 1 +output = 1 +`); + + try { + const result = await syncProvider({ + ...zenifra, + modelsDir, + fetchModels: async () => ({ + object: "list", + data: [zenifraModel({ + id: "zenifra/unknown", + capabilities: { reasoning: { supported: false } }, + supported_parameters: [], + })], + }), + }, { dryRun: true, openIssues: false }); + + expect(result.deleted).toBe(0); + expect(result.notices).toContain( + "1 local Zenifra models were absent from the live API and were retained for manual lifecycle review.", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +});