feat(models,landing): add OpenRouter free models and a free-models article (#60)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -221,9 +221,10 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [
|
||||
},
|
||||
// -- OpenRouter (gateway: OpenAI-compatible protocol, preset base URL). Entries added
|
||||
// 2026-07-20 list no cache pricing on their OpenRouter pages, so cache_read carries the
|
||||
// standard input price; their :free tier stores a genuine $0 price (not "unknown"), so
|
||||
// costs correctly compute to 0. GPT models are uniformly vision-capable (OpenAI
|
||||
// product-line policy) even where the gateway page omits the modality.
|
||||
// standard input price; the :free tier and the openrouter/free Free Models Router store a
|
||||
// genuine $0 price (not "unknown"), so costs correctly compute to 0. GPT models are
|
||||
// uniformly vision-capable (OpenAI product-line policy) even where the gateway page omits
|
||||
// the modality.
|
||||
// Two cache_read conventions coexist in this block: rows whose upstream publishes a
|
||||
// cache-hit price store that real price (the google/gemini-3.6-flash and
|
||||
// google/gemini-3.5-flash-lite entries below), while the 2026-07-20 rows still repeat the
|
||||
@@ -239,6 +240,20 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [
|
||||
clientType: "openai",
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
},
|
||||
{
|
||||
// Upstream publishes full cache pricing for this row (2026-07-24, per the OpenRouter
|
||||
// models API: $0.50/mtok cache hit, $6.25 cache write = 1.25 x input, $5 input, $25
|
||||
// output), so cache_read stores the real discounted price — same convention as the
|
||||
// Gemini rows below — instead of repeating the input price.
|
||||
modelId: "anthropic/claude-opus-5",
|
||||
displayName: "Claude Opus 5",
|
||||
provider: "openrouter",
|
||||
contextWindow: 1000000,
|
||||
pricing: usd(0.5, 6.25, 25),
|
||||
supportsVision: true,
|
||||
clientType: "openai",
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
},
|
||||
{
|
||||
modelId: "anthropic/claude-opus-4.8",
|
||||
displayName: "Claude Opus 4.8",
|
||||
@@ -328,6 +343,19 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [
|
||||
clientType: "openai",
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
},
|
||||
{
|
||||
// inclusionAI's free tier of Ling-3.0-flash (released 2026-07-23): 124B-param MoE with
|
||||
// ~5.1B active params per token, text-only; context and $0 pricing per the OpenRouter
|
||||
// models API (2026-07-24).
|
||||
modelId: "inclusionai/ling-3.0-flash:free",
|
||||
displayName: "Ling 3.0 Flash (free)",
|
||||
provider: "openrouter",
|
||||
contextWindow: 262144,
|
||||
pricing: usd(0, 0, 0),
|
||||
supportsVision: false,
|
||||
clientType: "openai",
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
},
|
||||
{
|
||||
// No official separate cache price published: cache_read uses the standard input price (no discount assumed).
|
||||
modelId: "minimax/minimax-m3",
|
||||
@@ -399,6 +427,36 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [
|
||||
clientType: "openai",
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
},
|
||||
{
|
||||
// OpenRouter's unified Free Models Router: each request is routed to a random free model
|
||||
// currently on OpenRouter, filtered by the features the request needs (tool calling,
|
||||
// structured outputs, ...). Routed targets vary, so the context window is a deliberately
|
||||
// conservative figure rather than any single target's real window: it keeps the 75%
|
||||
// compaction clamp meaningful (compaction fires at 96000) and reduces hard context-length
|
||||
// 400s on small-window targets. supportsVision stays false deliberately: the harness must
|
||||
// not send images to a router whose target may be text-only.
|
||||
modelId: "openrouter/free",
|
||||
displayName: "Free Models Router",
|
||||
provider: "openrouter",
|
||||
contextWindow: 128000,
|
||||
pricing: usd(0, 0, 0),
|
||||
supportsVision: false,
|
||||
clientType: "openai",
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
},
|
||||
{
|
||||
// Poolside's free tier of Laguna M.1, its flagship coding-agent model (agentic coding
|
||||
// workflows with tool calling and reasoning), text-only; context and $0 pricing per the
|
||||
// OpenRouter models API (2026-07-24).
|
||||
modelId: "poolside/laguna-m.1:free",
|
||||
displayName: "Laguna M.1 (free)",
|
||||
provider: "openrouter",
|
||||
contextWindow: 262144,
|
||||
pricing: usd(0, 0, 0),
|
||||
supportsVision: false,
|
||||
clientType: "openai",
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
},
|
||||
{
|
||||
// Neither the OpenRouter page nor AgentHub's registry publishes a cache price for this
|
||||
// model, so cache_read repeats the input price (no discount assumed).
|
||||
|
||||
@@ -83,8 +83,9 @@ describe("model-catalog", () => {
|
||||
for (const m of MODEL_CATALOG) {
|
||||
if (UNPRICED.has(`${m.provider}\0${m.modelId}`)) {
|
||||
expect(m.pricing, m.modelId).toBeUndefined();
|
||||
} else if (m.modelId.endsWith(":free")) {
|
||||
// Free-tier gateway model: a genuine $0 price (not "unknown"), so costs compute to 0.
|
||||
} else if (m.modelId.endsWith(":free") || m.modelId === "openrouter/free") {
|
||||
// Free-tier gateway model (:free variants and the openrouter/free router): a genuine
|
||||
// $0 price (not "unknown"), so costs compute to 0.
|
||||
expect(m.pricing, m.modelId).toBeDefined();
|
||||
expect([m.pricing!.cache_read, m.pricing!.cache_write, m.pricing!.output]).toEqual([
|
||||
0, 0, 0,
|
||||
@@ -152,6 +153,7 @@ describe("model-catalog", () => {
|
||||
// opus-4.8 before 4.7) — precomputed in the catalog, no runtime sorting.
|
||||
expect(or.map((m) => m.modelId)).toEqual([
|
||||
"anthropic/claude-fable-5",
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"anthropic/claude-sonnet-5",
|
||||
@@ -160,6 +162,7 @@ describe("model-catalog", () => {
|
||||
"google/gemini-3.6-flash",
|
||||
"google/gemini-3.5-flash",
|
||||
"google/gemini-3.5-flash-lite",
|
||||
"inclusionai/ling-3.0-flash:free",
|
||||
"minimax/minimax-m3",
|
||||
"moonshotai/kimi-k3",
|
||||
"moonshotai/kimi-k2.6",
|
||||
@@ -167,6 +170,8 @@ describe("model-catalog", () => {
|
||||
"openai/gpt-5.6-sol",
|
||||
"openai/gpt-5.6-terra",
|
||||
"openai/gpt-5.5",
|
||||
"openrouter/free",
|
||||
"poolside/laguna-m.1:free",
|
||||
"qwen/qwen3.6-35b-a3b",
|
||||
"stepfun/step-3.7-flash",
|
||||
"tencent/hy3",
|
||||
|
||||
@@ -73,6 +73,8 @@ Built-in groups and their env-var fallbacks (catalog source: `packages/core/src/
|
||||
|
||||
The gateway groups (openrouter / fireworks / siliconflow / qwen-token-plan / qwen-pay-as-you-go) go through AgentHub's OpenAI client, so with blank credentials they read `OPENAI_API_KEY` — not a gateway-specific variable.
|
||||
|
||||
The preset catalog also carries OpenRouter's free tier: `:free` model variants (e.g. `inclusionai/ling-3.0-flash:free`, `poolside/laguna-m.1:free`) and the `openrouter/free` unified Free Models Router. They cost nothing, but are subject to OpenRouter's free-tier rate limits and data policy.
|
||||
|
||||
Some models in the preset catalog: deepseek-v4-pro / deepseek-v4-flash, gemini-3.1-pro-preview, claude-opus-4-8 / claude-sonnet-4-6, gpt-5.5, glm-5.2, kimi-k2.6, qwen3.8-max-preview (not exhaustive).
|
||||
|
||||
## Thinking levels
|
||||
|
||||
@@ -73,6 +73,8 @@ api_key = "sk-..."
|
||||
|
||||
网关分组(openrouter / fireworks / siliconflow / qwen-token-plan / qwen-pay-as-you-go)经 AgentHub 的 OpenAI 客户端请求,因此凭证留空时读取的是 `OPENAI_API_KEY`,而非网关自己的变量名。
|
||||
|
||||
预置目录还收录了 OpenRouter 的免费档:`:free` 模型变体(如 `inclusionai/ling-3.0-flash:free`、`poolside/laguna-m.1:free`)与统一路由 `openrouter/free`(Free Models Router),零成本可用,但受 OpenRouter 免费档速率限制与数据政策约束。
|
||||
|
||||
预置目录中的部分模型:deepseek-v4-pro / deepseek-v4-flash、gemini-3.1-pro-preview、claude-opus-4-8 / claude-sonnet-4-6、gpt-5.5、glm-5.2、kimi-k2.6、qwen3.8-max-preview 等(非完整清单)。
|
||||
|
||||
## 思考等级
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Introducing the free models in PenguinHarness
|
||||
date: 2026-07-24
|
||||
category: news
|
||||
excerpt: The preset catalog now carries four zero-cost OpenRouter entries — Nemotron 3 Ultra (free), the new Ling 3.0 Flash (free) and Laguna M.1 (free), and the new Free Models Router. One OpenRouter API key and an agent is running, no balance required. Here is the lineup, how to switch it on, and an honest account of what the free tier does and does not buy you.
|
||||
---
|
||||
|
||||
Trying an agent harness should not start with a top-up. PenguinHarness ships free models in its preset catalog: rows priced at $0 per million tokens, wired up like every other preset — protocol, base URL, pricing and context window pre-filled — so the only thing between you and a running agent is an OpenRouter API key, which is itself free to create.
|
||||
|
||||
As of today the free lineup is four entries, all in the OpenRouter group:
|
||||
|
||||
| Provider group | Model id | Context | Price |
|
||||
| -------------- | ---------------------------------------- | -------------: | ----- |
|
||||
| OpenRouter | `nvidia/nemotron-3-ultra-550b-a55b:free` | 1,000,000 | $0 |
|
||||
| OpenRouter | `inclusionai/ling-3.0-flash:free` | 262,144 | $0 |
|
||||
| OpenRouter | `poolside/laguna-m.1:free` | 262,144 | $0 |
|
||||
| OpenRouter | `openrouter/free` | 128,000 (conservative) | $0 |
|
||||
|
||||

|
||||
|
||||
## Nemotron 3 Ultra (free)
|
||||
|
||||
The catalog's first free row, and still its largest: NVIDIA's open frontier-reasoning and orchestration model, a Mixture-of-Experts with 55B active parameters out of 550B total on a hybrid Transformer–Mamba architecture, with a 1M-token context window. If you want to see what the harness's planning-heavy loops look like on a big reasoning model — without paying big-reasoning-model prices — this is the row.
|
||||
|
||||
## New: Ling 3.0 Flash (free)
|
||||
|
||||
Released by inclusionAI on July 23, in the catalog the next day. Ling-3.0-flash is a 124B-parameter MoE that activates only ~5.1B parameters per token, and inclusionAI's stated design priorities are token efficiency and production-scale agentic inference, tool calling included. That reads like a description of what an agent harness does all day: dozens of short round trips, each carrying a tool schema and a growing transcript, where efficiency per step is the whole cost model. A sparse, tool-tuned model at $0 is a very good default for exactly that traffic. Context is 262K; text only.
|
||||
|
||||
## New: Laguna M.1 (free)
|
||||
|
||||
Alongside Ling comes the free tier of Poolside's flagship coding-agent model. Laguna M.1 is optimized for complex software-engineering work — agentic coding workflows with tool calling and reasoning, which is precisely the traffic a harness generates. Context is 262K; text only.
|
||||
|
||||
## New: Free Models Router
|
||||
|
||||
`openrouter/free` is not a model but OpenRouter's unified free-tier endpoint: each request is routed to a random free model currently available on OpenRouter, filtered so the target supports what the request actually needs — tool calling, structured outputs, and so on. Free models come and go upstream; the router keeps answering, and you never chase the current list yourself.
|
||||
|
||||
Two catalog decisions are worth knowing. The routed target's real context window varies per request, so the row records a deliberately conservative 128,000 rather than any single target's real figure — long Sessions compact early instead of growing toward a window the routed model may not have. And it is marked text-only on purpose: the router itself accepts images, but the model behind any given request may not, so PenguinHarness keeps images off this route and falls back to its usual text-only hand-off (file path plus `describe_image`).
|
||||
|
||||
## Switching it on
|
||||
|
||||
1. Create an API key at [openrouter.ai](https://openrouter.ai/) — the free tier needs no payment method.
|
||||
2. A new Project carries the presets already: open the **Models** page and paste the key on the OpenRouter group's bulk key button. An existing Project picks the new rows up with one click on **Sync presets** next to the Models page's search box — locally added models and stored credentials are untouched.
|
||||
3. Or from the terminal:
|
||||
|
||||
```bash
|
||||
penguin config model add --provider openrouter --model-id inclusionai/ling-3.0-flash:free --api-key <your-key> --set-default
|
||||
penguin config model list
|
||||
```
|
||||
|
||||
Set a free row as the Project default, or leave the default alone and pick one in the model selector when starting a Session — models are chosen per Session, not bound to an Agent. Free rows carry a light-yellow "Free" badge on the Models page and in the model picker, so they are easy to spot.
|
||||
|
||||
## What free buys you, and what it does not
|
||||
|
||||
The caveats, plainly:
|
||||
|
||||
- **Rate limits.** OpenRouter's free tier caps requests per minute and per day; a long Session or a benchmark run can hit them.
|
||||
- **Data policy.** Free models run under OpenRouter's free-model terms, and prompts may be used by the upstream provider as its terms allow. Send nothing you would not share.
|
||||
- **Availability and quality vary.** Free capacity is whatever providers choose to offer; models get busy, get slower, and get withdrawn.
|
||||
- **The router's target changes per request.** Its real context window varies with it — hence the conservative recorded window, which makes long Sessions compact early — and consistency is not what `openrouter/free` is for; a fixed free row gives you more of it, a paid row the most.
|
||||
|
||||
Free models are a genuine way to experience the full harness — Workspaces, tools, Skills, subagents, the Cost center reading a clean $0 — and to run light automation. For serious work, pick a paid model; the same catalog carries plenty.
|
||||
|
||||
## Get it
|
||||
|
||||
```bash
|
||||
curl -fsSL https://penguin.ooo/install.sh | sh
|
||||
penguin web
|
||||
```
|
||||
|
||||
Then open the Models page, drop in an OpenRouter key, and pick a free row.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: 介绍 PenguinHarness 里的免费模型
|
||||
date: 2026-07-24
|
||||
category: news
|
||||
excerpt: 预置目录现有四条 $0 价格的 OpenRouter 条目——已有的 Nemotron 3 Ultra (free),以及新增的 Ling 3.0 Flash (free)、Laguna M.1 (free) 与 Free Models Router。一个免费注册的 OpenRouter API Key 就能把 Agent 跑起来,无需充值。本文介绍这套免费阵容、开启方式,以及免费档能换来什么、换不来什么。
|
||||
---
|
||||
|
||||
试用一个 Agent Harness,不应该从充值开始。PenguinHarness 的预置目录里带着免费模型:每百万 Token 价格为 $0 的模型行,和其他预置条目一样填好了协议、base URL、价格与上下文窗口——你与一个跑起来的 Agent 之间,只隔着一个 OpenRouter API Key,而注册它本身也是免费的。
|
||||
|
||||
截至今天,免费阵容共四条,全部在 OpenRouter 分组下:
|
||||
|
||||
| 提供方分组 | 模型 ID | 上下文 | 价格 |
|
||||
| ---------- | ---------------------------------------- | ---------------: | ---- |
|
||||
| OpenRouter | `nvidia/nemotron-3-ultra-550b-a55b:free` | 1,000,000 | $0 |
|
||||
| OpenRouter | `inclusionai/ling-3.0-flash:free` | 262,144 | $0 |
|
||||
| OpenRouter | `poolside/laguna-m.1:free` | 262,144 | $0 |
|
||||
| OpenRouter | `openrouter/free` | 128,000(保守值) | $0 |
|
||||
|
||||

|
||||
|
||||
## Nemotron 3 Ultra (free)
|
||||
|
||||
目录里的第一条免费行,也仍是最大的一条:NVIDIA 的开放前沿推理与编排模型,MoE 架构,总参数 550B、每 Token 激活 55B,混合 Transformer–Mamba,上下文窗口 1M Token。想看看 Harness 的重规划循环跑在大推理模型上是什么样、又不想付大推理模型的价格,就选这一行。
|
||||
|
||||
## 新增:Ling 3.0 Flash (free)
|
||||
|
||||
inclusionAI 于 7 月 23 日发布,次日进入目录。Ling-3.0-flash 是一个 124B 参数的 MoE 模型,每个 Token 只激活约 5.1B 参数;inclusionAI 给它的设计目标是 Token 效率与生产规模的 Agent 推理,含工具调用。这几乎就是在描述一个 Agent Harness 每天做的事:几十次短往返,每一次都带着工具 Schema 和不断增长的对话记录,单步效率就是全部的成本模型。一个稀疏、面向工具调用调优、价格为 $0 的模型,正适合承接这类流量。上下文 262K,仅文本。
|
||||
|
||||
## 新增:Laguna M.1 (free)
|
||||
|
||||
与 Ling 一同进入目录的,还有 Poolside 旗舰编码 Agent 模型的免费档。Laguna M.1 面向复杂软件工程任务优化——支持工具调用与推理的 Agent 编码工作流,而这正是 Harness 产生的流量形态。上下文 262K,仅文本。
|
||||
|
||||
## 新增:Free Models Router
|
||||
|
||||
`openrouter/free` 不是一个模型,而是 OpenRouter 的统一免费端点:每个请求被随机路由到 OpenRouter 上当前可用的某个免费模型,并按请求真正需要的能力过滤——工具调用、结构化输出等。上游的免费模型来来去去,这个路由始终有答案,你不必自己盯着最新清单。
|
||||
|
||||
有两个目录层面的决定值得说明。被路由到的目标随请求变化,其真实上下文窗口也随之变化,所以这一行刻意记录一个保守值 128,000,而非任何单一目标的真实窗口——长会话会提前压缩,而不是向被路由模型未必拥有的窗口继续膨胀。它也被刻意标记为仅文本:路由本身接受图片,但任一请求背后的模型未必支持,所以 PenguinHarness 不向这条路由发送图片,改走常规的纯文本交接(文件路径加 `describe_image`)。
|
||||
|
||||
## 开启方式
|
||||
|
||||
1. 在 [openrouter.ai](https://openrouter.ai/) 注册并创建 API Key——免费档不需要绑定支付方式。
|
||||
2. 新建 Project 自带这些预置条目:打开 **Models** 页面,用 OpenRouter 分组的批量填 Key 按钮贴上 Key 即可。已有 Project 点一下搜索框旁的**同步预置**就能拿到新行——本地添加的模型与已存凭证都不会被动到。
|
||||
3. 也可以走终端:
|
||||
|
||||
```bash
|
||||
penguin config model add --provider openrouter --model-id inclusionai/ling-3.0-flash:free --api-key <your-key> --set-default
|
||||
penguin config model list
|
||||
```
|
||||
|
||||
把某条免费行设为 Project 默认,或者不动默认、在创建会话时于模型选择器里现选一条——模型按 Session 选择,不绑定 Agent。免费行在 Models 页面与模型选择器中都带淡黄色「免费」标签,一眼可辨。
|
||||
|
||||
## 免费换来什么,换不来什么
|
||||
|
||||
把话说在前面:
|
||||
|
||||
- **速率限制。** OpenRouter 免费档限制每分钟与每天的请求数;一段长会话或一轮评测就可能碰到上限。
|
||||
- **数据政策。** 免费模型按 OpenRouter 的免费模型条款运行,Prompt 可能在上游条款允许的范围内被其使用。不要发送任何你不愿意公开的内容。
|
||||
- **可用性与质量会波动。** 免费容量取决于提供方的意愿;模型会变忙、变慢,也会下线。
|
||||
- **路由目标逐请求变化。** 目标的真实上下文窗口也随之变化——因此目录记录的是上面那个保守值,让长会话尽早压缩;一致性本就不是 `openrouter/free` 的目标,固定选一条免费行会更一致,付费行最一致。
|
||||
|
||||
免费模型足以真实地体验完整的 Harness——Workspace、工具、Skill、子 Agent,以及费用中心里干干净净的 $0——也能承担轻量自动化。正经的工作请换付费模型,同一份目录里有的是。
|
||||
|
||||
## 获取方式
|
||||
|
||||
```bash
|
||||
curl -fsSL https://penguin.ooo/install.sh | sh
|
||||
penguin web
|
||||
```
|
||||
|
||||
然后打开 Models 页面,填入 OpenRouter Key,选一条免费行。
|
||||
@@ -10,7 +10,8 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"shots": "node scripts/capture-shots.mjs"
|
||||
"shots": "node scripts/capture-shots.mjs",
|
||||
"blog-shots": "node scripts/capture-blog-shots.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Capture blog-post screenshots of the product's Models page (the free-model rows).
|
||||
*
|
||||
* A deliberately lean sibling of capture-shots.mjs: that script drives a full scripted
|
||||
* LLM conversation for the landing hero shots, while the Models page needs no LLM at
|
||||
* all — a fresh user's default Project already presets the entire built-in model
|
||||
* catalog, so the free rows (Ling 3.0 Flash / Nemotron 3 Ultra / Laguna M.1 /
|
||||
* openrouter/free) render their light-yellow "Free" badges straight from the $0
|
||||
* pricing. Flow: boot the Web server against a temp data root serving the built web
|
||||
* dist -> provision one demo user per UI language (password rotated once so the
|
||||
* initial-password banner never shows) -> open /models, scroll the OpenRouter group
|
||||
* to the top of the viewport (Claude Opus 5 sits on its first card row, the free
|
||||
* rows a few rows below) -> screenshot, light theme, zh + en, into
|
||||
* public/blog-assets/ as free-models-page-<lang>-light.webp (re-encoded to WebP
|
||||
* inside Chromium, same as capture-shots.mjs).
|
||||
*
|
||||
* Prereqs: `pnpm --filter @prismshadow/penguin-{skills,core,server,web} build` and
|
||||
* Playwright's chromium. Run: `node scripts/capture-blog-shots.mjs` (or
|
||||
* `pnpm --filter @prismshadow/penguin-landing blog-shots`).
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(HERE, "../../..");
|
||||
const OUT_DIR = path.resolve(HERE, "../public/blog-assets");
|
||||
const SRV_PORT = 8944; // Distinct from capture-shots.mjs (8940/8941) so both can run.
|
||||
// On loopback binds the App is canonically served on `localhost`; the 127.0.0.1
|
||||
// counterpart is the Workspace-preview host, where /api deliberately answers 401
|
||||
// (see server app.ts's canonical-host guard).
|
||||
const BASE = `http://localhost:${SRV_PORT}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server + API helpers (same shape as capture-shots.mjs).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function waitFor(url, tries = 60) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return;
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`server not ready: ${url}`);
|
||||
}
|
||||
|
||||
async function api(cookie, method, url, body) {
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${method} ${url} -> ${res.status} ${await res.text()}`);
|
||||
return { json: await res.json().catch(() => ({})), setCookie: res.headers.get("set-cookie") };
|
||||
}
|
||||
|
||||
async function login(userId, password) {
|
||||
const { setCookie } = await api(null, "POST", "/api/auth/login", { userId, password });
|
||||
if (!setCookie) throw new Error("no session cookie from login");
|
||||
return setCookie.split(";")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-language demo user (same ids as capture-shots.mjs). Unlike that script it does NOT
|
||||
* touch the model table: the fresh Project's preset catalog is exactly what the shot is
|
||||
* about. The password is rotated once so the initial-password banner stays out of frame.
|
||||
*/
|
||||
async function provisionUser(adminCookie, lang) {
|
||||
const userId = lang === "zh" ? "demo" : "alex";
|
||||
const initial = `${userId}12345`;
|
||||
await api(adminCookie, "POST", "/api/admin/users", { userId, password: initial }).catch((e) => {
|
||||
if (!String(e).includes("409")) throw e;
|
||||
});
|
||||
let password = initial;
|
||||
const cookie = await login(userId, initial);
|
||||
try {
|
||||
await api(cookie, "PUT", "/api/me/password", {
|
||||
oldPassword: initial,
|
||||
newPassword: `penguin-${userId}-2026`,
|
||||
});
|
||||
password = `penguin-${userId}-2026`;
|
||||
} catch {}
|
||||
return { userId, password };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const dataRoot = mkdtempSync(path.join(os.tmpdir(), "penguin-blog-shots-"));
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const srv = spawn("node", [path.join(ROOT, "packages/server/dist/index.js")], {
|
||||
env: {
|
||||
...process.env,
|
||||
PENGUIN_HOME: path.join(dataRoot, "home"),
|
||||
PENGUIN_WEB_DB: path.join(dataRoot, "web.db"),
|
||||
PENGUIN_WEB_DIST: path.join(ROOT, "packages/web/dist"),
|
||||
PORT: String(SRV_PORT),
|
||||
HOST: "127.0.0.1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
srv.stderr.on("data", (d) => process.stderr.write(`[srv!] ${d}`));
|
||||
process.on("exit", () => {
|
||||
try {
|
||||
srv.kill();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
try {
|
||||
await waitFor(`${BASE}/`);
|
||||
console.log(`[blog-shots] server ready on ${BASE}`);
|
||||
|
||||
const adminCookie = await login("admin", "penguin-2026");
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// WebP encoder: Chromium re-encodes the PNG screenshot buffer via canvas (same
|
||||
// convention and quality as capture-shots.mjs, keeping repo assets small).
|
||||
const encoderPage = await browser.newPage();
|
||||
async function saveWebp(pngBuffer, fileName) {
|
||||
const dataUrl = await encoderPage.evaluate(async (b64) => {
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||
return canvas.toDataURL("image/webp", 0.82);
|
||||
}, pngBuffer.toString("base64"));
|
||||
writeFileSync(path.join(OUT_DIR, fileName), Buffer.from(dataUrl.split(",")[1], "base64"));
|
||||
console.log(`[blog-shots] ${fileName}`);
|
||||
}
|
||||
|
||||
for (const lang of ["zh", "en"]) {
|
||||
const user = await provisionUser(adminCookie, lang);
|
||||
|
||||
// 1280x900 @1.5x, light theme (the blog embeds a single light variant per language,
|
||||
// like rag-app-<lang>-light.webp): tall enough that the OpenRouter group shows Claude
|
||||
// Opus 5 (first card row) together with the free rows and their badges.
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
deviceScaleFactor: 1.5,
|
||||
locale: lang === "zh" ? "zh-CN" : "en-US",
|
||||
});
|
||||
await context.addInitScript(
|
||||
([t, l]) => {
|
||||
localStorage.setItem("penguin.theme", t);
|
||||
localStorage.setItem("penguin.lang", l);
|
||||
},
|
||||
["light", lang],
|
||||
);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${BASE}/login`);
|
||||
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { userId: user.userId, password: user.password },
|
||||
});
|
||||
if (!loginRes.ok()) throw new Error(`browser login failed: ${loginRes.status()}`);
|
||||
|
||||
await page.goto(`${BASE}/models`);
|
||||
// The catalog is loaded once a free row's card is rendered (its "Free" badge with it).
|
||||
const lingCard = page.getByText("Ling 3.0 Flash (free)").first();
|
||||
await lingCard.waitFor({ timeout: 20000 });
|
||||
// Scroll the OpenRouter group section to the top of the viewport so the group header,
|
||||
// Claude Opus 5 (first card row) and the badged free rows all sit in frame.
|
||||
await page
|
||||
.getByRole("button", { name: /OpenRouter/ })
|
||||
.first()
|
||||
.evaluate((el) =>
|
||||
el.closest("section").scrollIntoView({ block: "start", behavior: "instant" }),
|
||||
);
|
||||
await page.waitForTimeout(1200);
|
||||
await saveWebp(await page.screenshot(), `free-models-page-${lang}-light.webp`);
|
||||
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`[blog-shots] done -> ${OUT_DIR}`);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("[blog-shots] FAILED:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const ROTATE_MS = 6000;
|
||||
* freshest news goes at the front.
|
||||
*/
|
||||
const ITEMS = [
|
||||
{ key: "freeModels", to: "/blog/free-models-in-penguin-harness" },
|
||||
{ key: "gemini", to: "/blog/gemini-3-6-in-penguinharness" },
|
||||
{ key: "models", to: "/blog/introducing-penguinharness" },
|
||||
{ key: "fireworks", to: "/blog/fireworks-credits-amd" },
|
||||
@@ -33,6 +34,7 @@ function prefersReducedMotion(): boolean {
|
||||
|
||||
export function AnnouncementBar() {
|
||||
const texts: Record<(typeof ITEMS)[number]["key"], string> = {
|
||||
freeModels: S.announcement.freeModels,
|
||||
gemini: S.announcement.gemini,
|
||||
models: S.announcement.models,
|
||||
fireworks: S.announcement.fireworks,
|
||||
|
||||
@@ -12,6 +12,7 @@ export const en: Strings = {
|
||||
label: "Announcements",
|
||||
prev: "Previous announcement",
|
||||
next: "Next announcement",
|
||||
freeModels: "Free models Ling 3.0 Flash and the Free Models Router are now in PenguinHarness",
|
||||
gemini: "Gemini 3.6 Flash is now available in PenguinHarness",
|
||||
models: "Kimi K3 and Qwen 3.8 Max are now available in PenguinHarness",
|
||||
fireworks: "Claim $50 in Fireworks API credits with the AMD Developer Program",
|
||||
|
||||
@@ -13,6 +13,7 @@ export const zh = {
|
||||
label: "公告",
|
||||
prev: "上一条公告",
|
||||
next: "下一条公告",
|
||||
freeModels: "Ling 3.0 Flash 与 Free Models Router 等免费模型现已在 PenguinHarness 可用",
|
||||
gemini: "Gemini 3.6 Flash 现已在 PenguinHarness 可用",
|
||||
models: "Kimi K3 与 Qwen 3.8 Max 模型现已在 PenguinHarness 可用",
|
||||
fireworks: "携手 AMD 开发者计划:$50 Fireworks API 额度免费领取中",
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("frontmatter mapping (author / pinned / category)", () => {
|
||||
it("reads the pinned flag and sorts the pinned post first", () => {
|
||||
for (const locale of ["en", "zh"] as const) {
|
||||
const posts = postsFor(locale);
|
||||
expect(posts.length).toBe(10);
|
||||
expect(posts.length).toBe(11);
|
||||
// The launch post stays the single pinned post; newer posts sort under it by date.
|
||||
expect(posts.filter((p) => p.pinned).map((p) => p.slug)).toEqual([
|
||||
"introducing-penguinharness",
|
||||
@@ -120,6 +120,7 @@ describe("frontmatter mapping (author / pinned / category)", () => {
|
||||
it("filters by the news category, newest first", () => {
|
||||
expect(postsFor("en", "news").map((p) => p.slug)).toEqual([
|
||||
"introducing-penguinharness",
|
||||
"free-models-in-penguin-harness",
|
||||
"gemini-3-6-in-penguinharness",
|
||||
"fireworks-credits-amd",
|
||||
]);
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type BadgeTone = "gray" | "brand" | "green" | "amber" | "red";
|
||||
export type BadgeTone = "gray" | "brand" | "green" | "yellow" | "amber" | "red";
|
||||
|
||||
const toneClass: Record<BadgeTone, string> = {
|
||||
gray: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300",
|
||||
brand: "bg-gray-200/80 text-gray-700 dark:bg-gray-700/60 dark:text-gray-200",
|
||||
green: "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
|
||||
// Light-yellow: a neutral informational tag (the "Free" model badge). Kept on the yellow
|
||||
// palette so it stays visually distinct from the amber tone below, which existing badges
|
||||
// use with warning semantics (aborted stop_reason, the proxy-vision badge on the same card).
|
||||
yellow: "bg-yellow-50 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300",
|
||||
amber: "bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",
|
||||
red: "bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300",
|
||||
};
|
||||
|
||||
@@ -61,7 +61,13 @@ import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { SkillIcon } from "../skills/skill-icon-view";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
import { ProviderLogo } from "../../components/ui/provider-logo";
|
||||
import { hasConfiguredKey, sameModelRef, visibleChatModels } from "../models/model-grouping";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import {
|
||||
hasConfiguredKey,
|
||||
isFreeModel,
|
||||
sameModelRef,
|
||||
visibleChatModels,
|
||||
} from "../models/model-grouping";
|
||||
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
|
||||
import { matchSlash, removeSlashToken } from "./slash-token";
|
||||
import { SELECTABLE_THINKING_LEVELS, thinkingLevelLabel } from "./thinking-level";
|
||||
@@ -348,6 +354,13 @@ function ModelSelect({
|
||||
>
|
||||
<ProviderLogo provider={m.provider} className="h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{modelLabel(m)}</span>
|
||||
{/* Zero-cost rows (all three price buckets 0): same light-yellow "Free" badge as
|
||||
the model library card, so free models stand out while picking. */}
|
||||
{isFreeModel(m.pricing) && (
|
||||
<span className="shrink-0">
|
||||
<Badge tone="yellow">{S.models.freeBadge}</Badge>
|
||||
</span>
|
||||
)}
|
||||
{/* Key-less rows (visible via show-all / selected / default / no-key-at-all) carry a
|
||||
struck-through key icon (the "no key" text lives in the title/aria-label). */}
|
||||
{!hasConfiguredKey(m) && (
|
||||
|
||||
@@ -114,6 +114,24 @@ export function hasConfiguredKey(m: ModelCredentialRowLike): boolean {
|
||||
return !!m.credential?.apiKeyMasked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free model detection (drives the light-yellow "Free" badge on the model card and in the
|
||||
* chat model picker): the entry carries explicit pricing and all three buckets are 0 — covers the
|
||||
* catalog's :free variants and the openrouter/free router — while unpriced models (no pricing
|
||||
* at all, costs merely unknown) stay unbadged. Accepts the DTO's numeric pricing buckets or
|
||||
* the model page's string-typed edit fields ("" = unpriced).
|
||||
*/
|
||||
export function isFreeModel(
|
||||
pricing:
|
||||
| { cacheRead: number | string; cacheWrite: number | string; output: number | string }
|
||||
| undefined,
|
||||
): boolean {
|
||||
if (!pricing) return false;
|
||||
return [pricing.cacheRead, pricing.cacheWrite, pricing.output].every((b) =>
|
||||
typeof b === "string" ? b.trim() !== "" && Number(b) === 0 : b === 0,
|
||||
);
|
||||
}
|
||||
|
||||
export interface VisibleChatModelsOptions {
|
||||
/** true = list every model (the dropdown's expanded "show all" state). */
|
||||
showAll: boolean;
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
resolveModelEnv,
|
||||
} from "@prismshadow/penguin-core/model-catalog";
|
||||
import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||
import { groupModelRows, sameModelRef, userProviderInfo } from "./model-grouping";
|
||||
import { groupModelRows, isFreeModel, sameModelRef, userProviderInfo } from "./model-grouping";
|
||||
import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache";
|
||||
import { syncRowsWithCatalog } from "./catalog-sync";
|
||||
import { tpsTone, ttftTone } from "./speed-test";
|
||||
@@ -962,6 +962,10 @@ function ModelCard({
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-sm font-medium">{row.displayName ?? row.modelId}</span>
|
||||
{isDefault && <Badge tone="brand">{S.models.default}</Badge>}
|
||||
{/* Free rows (all three price buckets 0, e.g. :free variants / openrouter/free): a
|
||||
light-yellow badge so zero-cost models stand out at a glance (informational, kept
|
||||
distinct from the amber warning tone the proxy-vision badge uses). */}
|
||||
{isFreeModel(row) && <Badge tone="yellow">{S.models.freeBadge}</Badge>}
|
||||
{row.vision && <Badge tone="green">{S.models.visionBadge}</Badge>}
|
||||
{isVisionModel && <Badge tone="amber">{S.models.visionModelBadge}</Badge>}
|
||||
</span>
|
||||
|
||||
@@ -329,6 +329,7 @@ export const en: Strings = {
|
||||
vision: "Vision support",
|
||||
visionOffProxyHint: "Images are read via the vision proxy model",
|
||||
visionBadge: "Vision",
|
||||
freeBadge: "Free",
|
||||
visionModelBadge: "Proxy vision",
|
||||
setVisionModel: "Set as proxy vision model",
|
||||
visionModelHint: "Describes images via describe_image for models without vision",
|
||||
|
||||
@@ -308,6 +308,8 @@ export const zh = {
|
||||
/** Shown only while the vision switch is OFF: images are then read via the configured vision proxy model (describe_image). */
|
||||
visionOffProxyHint: "使用视觉代理模型读图",
|
||||
visionBadge: "视觉",
|
||||
/** Light-yellow badge on zero-cost models (all three price buckets 0, e.g. the :free variants and openrouter/free). */
|
||||
freeBadge: "免费",
|
||||
visionModelBadge: "视觉代理",
|
||||
setVisionModel: "设为视觉代理模型",
|
||||
visionModelHint: "供不支持图片的模型经 describe_image 代读图片",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MODEL_PROVIDERS } from "@prismshadow/penguin-core/model-catalog";
|
||||
import {
|
||||
groupModelRows,
|
||||
hasConfiguredKey,
|
||||
isFreeModel,
|
||||
matchesQuery,
|
||||
orderModelsLikeLibrary,
|
||||
visibleChatModels,
|
||||
@@ -252,3 +253,21 @@ describe("orderModelsLikeLibrary", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFreeModel", () => {
|
||||
it("numeric buckets (the DTO shape): free ⇔ pricing exists and all three buckets are 0", () => {
|
||||
expect(isFreeModel({ cacheRead: 0, cacheWrite: 0, output: 0 })).toBe(true);
|
||||
expect(isFreeModel({ cacheRead: 0, cacheWrite: 0, output: 1.2 })).toBe(false);
|
||||
expect(isFreeModel({ cacheRead: 0.5, cacheWrite: 6.25, output: 25 })).toBe(false);
|
||||
// No pricing at all = costs merely unknown, not free.
|
||||
expect(isFreeModel(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('string-typed edit fields (the model page\'s RowState shape): "" means unpriced, not $0', () => {
|
||||
expect(isFreeModel({ cacheRead: "0", cacheWrite: "0", output: "0" })).toBe(true);
|
||||
expect(isFreeModel({ cacheRead: "", cacheWrite: "", output: "" })).toBe(false);
|
||||
// Partially filled pricing never counts as free.
|
||||
expect(isFreeModel({ cacheRead: "0", cacheWrite: "", output: "0" })).toBe(false);
|
||||
expect(isFreeModel({ cacheRead: "0", cacheWrite: "0", output: "3" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user