openclacky 1.2.7 → 1.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/lib/clacky/agent.rb +3 -0
  4. data/lib/clacky/agent_config.rb +91 -7
  5. data/lib/clacky/billing/billing_store.rb +107 -3
  6. data/lib/clacky/cli.rb +105 -0
  7. data/lib/clacky/client.rb +38 -5
  8. data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
  9. data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
  10. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
  11. data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
  12. data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
  13. data/lib/clacky/media/base.rb +68 -0
  14. data/lib/clacky/media/gemini.rb +36 -0
  15. data/lib/clacky/media/generator.rb +78 -0
  16. data/lib/clacky/media/openai_compat.rb +168 -0
  17. data/lib/clacky/patch_loader.rb +282 -0
  18. data/lib/clacky/providers.rb +82 -0
  19. data/lib/clacky/server/channel/adapters/base.rb +4 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +1 -1
  21. data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
  22. data/lib/clacky/server/channel.rb +5 -0
  23. data/lib/clacky/server/http_server.rb +236 -25
  24. data/lib/clacky/server/scheduler.rb +1 -4
  25. data/lib/clacky/shell_hook_loader.rb +181 -0
  26. data/lib/clacky/telemetry.rb +11 -5
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +326 -24
  29. data/lib/clacky/web/billing.js +117 -22
  30. data/lib/clacky/web/i18n.js +84 -6
  31. data/lib/clacky/web/index.html +14 -2
  32. data/lib/clacky/web/model-tester.js +58 -0
  33. data/lib/clacky/web/onboard.js +17 -30
  34. data/lib/clacky/web/settings.js +322 -97
  35. data/lib/clacky.rb +9 -0
  36. data/scripts/build/lib/network.sh +61 -30
  37. data/scripts/install.sh +61 -30
  38. data/scripts/install_browser.sh +61 -30
  39. data/scripts/install_full.sh +61 -30
  40. data/scripts/install_rails_deps.sh +61 -30
  41. data/scripts/install_system_deps.sh +61 -30
  42. metadata +12 -3
  43. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  name: deploy
3
3
  description: Deploy Rails applications to Railway. Handles first-time setup and re-deploys idempotently using Railway CLI. Trigger on: "deploy", "deploy to railway", "railway deploy", "发布", "部署", "上线".
4
- user-invocable: true
4
+ agent: coding
5
+ disable-model-invocation: false
5
6
  ---
6
7
 
7
8
  # Deploy Rails App to Railway
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: extend-openclacky
3
+ description: Customize, fix, override or extend openclacky itself — e.g. change a built-in tool's behavior, intercept/audit/block tool calls with shell scripts, or plug in a new IM channel (Slack, in-house IM, etc.). Trigger on phrases like "patch clacky", "patch openclacky", "change WebSearch behavior", "block dangerous commands", "audit tool use", "add Slack channel", "改 openclacky 内置", "改 clacky 内置", "monkey patch openclacky", "拦截工具调用". Do NOT trigger for ordinary feature work in the user's own project that doesn't touch openclacky.
4
+ ---
5
+
6
+ # Extending Openclacky
7
+
8
+ Openclacky ships three official extension mechanisms that survive `gem update` and never require editing the gem source.
9
+ **Never tell the user to `bundle show openclacky` and edit the gem — always use one of these.**
10
+
11
+ ## Pick the right mechanism
12
+
13
+ | User wants to… | Use | Scaffold | Verify |
14
+ |---|---|---|---|
15
+ | Change behavior of an **existing method** in openclacky (e.g. `WebSearch#execute` timeout, fix a bug in a built-in tool) | **Patch** | `clacky patch_new <id> "Const#method" -d "<desc>"` | `clacky patch_verify` |
16
+ | **Audit / block / observe** tool calls (block `rm -rf /`, log every shell command) — no Ruby needed | **Shell Hook** | `clacky hook_new <id> -e <event>` | `clacky hook_verify` |
17
+ | Plug openclacky into a **new IM platform** (Slack, in-house IM, custom webhook…) | **Channel Adapter** | `clacky channel_new <platform_id>` | `clacky channel_verify` |
18
+
19
+ ## Authoritative documentation
20
+
21
+ Each mechanism has a full reference doc — read the relevant one with `web_fetch` before writing code:
22
+
23
+ - Patches → https://www.openclacky.com/docs/extend-patches
24
+ - Shell Hooks → https://www.openclacky.com/docs/extend-shell-hooks
25
+ - Channel Adapters → https://www.openclacky.com/docs/extend-channel-adapter
26
+
27
+ ## Execution playbook
28
+
29
+ 1. **Identify** which mechanism fits (use the table above; ask if genuinely ambiguous).
30
+ 2. **Read the doc** for that mechanism with `web_fetch`. Don't guess fields, hook events, or required methods — the doc is the contract.
31
+ 3. **Run the scaffold** CLI command. It generates the file(s) in `~/.clacky/...` with correct meta.
32
+ 4. **Edit** the generated file to implement the user's intent. Keep generated meta fields (`target`, `event`, `platform_id`, the `Clacky::ChannelRegistry.register(...)` line, etc.) intact unless the doc says otherwise.
33
+ 5. **Verify** with the matching `*_verify` command. Surface any `[FAIL]` lines to the user verbatim.
34
+
35
+ ## When NOT to use this skill
36
+
37
+ - The user is building features in their own application that just *use* openclacky — that's normal coding, no patch/hook/channel needed.
38
+ - The user wants a brand-new tool/skill for *their* project — use `.clacky/skills/` or `.clacky/tools/`, not these gem-level mechanisms.
39
+ - The change can be made via `clacky config set ...` — prefer config over patches.
@@ -5,13 +5,6 @@ description: |
5
5
  reconfigure. Edits ~/.clacky/mcp.json so the user never writes JSON by hand.
6
6
  Trigger on: add mcp, install mcp, setup mcp, configure mcp, mcp list, mcp remove,
7
7
  mcp probe, mcp reconfigure.
8
- argument-hint: "add | list | probe <name> | remove <name> | reconfigure <name>"
9
- allowed-tools:
10
- - Bash
11
- - Read
12
- - Write
13
- - Edit
14
- - AskFollowupQuestion
15
8
  ---
16
9
 
17
10
  # MCP Manager Skill
@@ -0,0 +1,128 @@
1
+ ---
2
+ name: media-gen
3
+ description: 'Generate images (and later videos / audio) inside the current task. Use this skill whenever the user asks to create, generate, or produce a picture / image / illustration / cover / poster / icon / artwork — including phrases like 生成图片, 画一张, 做封面, 来张配图, generate image, make a picture, draw, create artwork, design a cover. Also use when building documents (slides, PPT, posters, marketing pages, README hero shots) where an image is needed inline. Routes calls through the local Clacky HTTP server, which uses the user-configured `type=image` model — you do NOT need to know which provider; the server handles it.'
4
+ disable-model-invocation: false
5
+ user-invocable: true
6
+ ---
7
+
8
+ # media-gen
9
+
10
+ Generate images on demand by calling the local Clacky HTTP server, which dispatches to whichever image-generation model the user configured (`type=image` in their model settings).
11
+
12
+ ## Endpoint
13
+
14
+ ```
15
+ POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/image
16
+ GET http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/types
17
+ ```
18
+
19
+ ## Step 1 — Verify a backend is configured
20
+
21
+ Before generating anything, confirm the user has a `type=image` model set up:
22
+
23
+ ```bash
24
+ curl -s http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/types
25
+ ```
26
+
27
+ If the response shows `image.configured = false`, stop and tell the user:
28
+
29
+ > 还没有配置生图模型。请打开 Clacky 设置页 → 添加模型 → 类型选 `image`(推荐 `or-gemini-3-pro-image` 或 `or-gpt-image-1`)。配好后再让我生图。
30
+
31
+ Do NOT try to fall back to `terminal` + a hand-written `curl https://api.openai.com/...` — that bypasses the user's configured backend and won't be billed correctly.
32
+
33
+ ## Step 2 — Generate the image
34
+
35
+ ```bash
36
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/media/image \
37
+ -H "Content-Type: application/json" \
38
+ -d '{
39
+ "prompt": "A clean, modern hero illustration for a tech startup landing page. Soft gradient background, abstract geometric shapes in blue and purple, minimal style, 4K quality.",
40
+ "aspect_ratio": "landscape"
41
+ }'
42
+ ```
43
+
44
+ ### Request fields
45
+
46
+ | Field | Required | Values | Notes |
47
+ |----------------|----------|-------------------------------------|-------|
48
+ | `prompt` | yes | string | Be detailed and concrete. See prompt tips below. |
49
+ | `aspect_ratio` | no | `landscape` / `square` / `portrait` | Defaults to `landscape`. |
50
+ | `output_dir` | no | absolute path | Defaults to the current working directory. The image is saved under `<output_dir>/assets/generated/`. |
51
+
52
+ ### Response shape (success)
53
+
54
+ ```json
55
+ {
56
+ "success": true,
57
+ "image": "/abs/path/to/working_dir/assets/generated/img_20260525_011820_a1b2c3d4.png",
58
+ "model": "or-gemini-3-pro-image",
59
+ "provider": "openclacky",
60
+ "prompt": "A clean, modern hero illustration ...",
61
+ "aspect_ratio": "landscape",
62
+ "size": "1536x1024",
63
+ "usage": {
64
+ "prompt_tokens": 50,
65
+ "completion_tokens": 4500,
66
+ "cache_read_tokens": 0,
67
+ "cache_write_tokens": 0,
68
+ "total_tokens": 4550
69
+ }
70
+ }
71
+ ```
72
+
73
+ The `image` field is an absolute path on disk. To embed it in markdown, slides, or HTML, convert it to a path relative to the document you're writing.
74
+
75
+ `usage` may be absent when the configured backend doesn't return token counts. Treat it as optional.
76
+
77
+ ### Response shape (failure)
78
+
79
+ ```json
80
+ {
81
+ "success": false,
82
+ "image": null,
83
+ "error": "Upstream 401: Invalid API key",
84
+ "error_type": "api_error",
85
+ "model": "...",
86
+ "provider": "..."
87
+ }
88
+ ```
89
+
90
+ Common `error_type` values: `not_configured`, `auth_required`, `network_error`, `api_error`, `empty_response`. Tell the user the error plainly; if it's `auth_required` or `api_error 401/403`, point them at settings to fix the api_key.
91
+
92
+ ## Step 3 — Show the image
93
+
94
+ `Read` does NOT show the image to the user — it only feeds it into your own context. To make the user actually see it, write a markdown tag in your reply:
95
+
96
+ ```markdown
97
+ ![](file:///abs/path/from/response.png)
98
+ ```
99
+
100
+ Take the `image` field from the response and prefix `file://` (three slashes, since the path is absolute).
101
+
102
+ If you're also embedding it in a document (README, PPT, etc.), use a relative path: `![](./assets/generated/xxx.png)`.
103
+
104
+ ## Prompt writing tips
105
+
106
+ A good image prompt has 4 layers, in this order:
107
+
108
+ 1. **Subject** — what is in the image, concretely. ("a golden retriever puppy", "a stylized icon of a rocket")
109
+ 2. **Style / medium** — photo / illustration / 3D render / watercolor / flat vector / line art
110
+ 3. **Composition / lighting** — close-up / wide shot / overhead / soft natural light / dramatic backlight
111
+ 4. **Mood / palette** — minimal / playful / corporate / pastel / high-contrast monochrome
112
+
113
+ For PPT / slide decks specifically:
114
+ - Hero / cover slides: `aspect_ratio: landscape`, prompt should emphasise "clean", "minimal", "negative space" so text overlays well
115
+ - Section dividers: `aspect_ratio: landscape`, abstract or pattern-style works better than literal subjects
116
+ - Inline figures: `aspect_ratio: square` or `portrait`, more literal subject is fine
117
+
118
+ When the user gives a vague request like "给我配张图", ask one clarifying question (subject? style?) before calling the API — costs real money per image.
119
+
120
+ ## When NOT to use this skill
121
+
122
+ - The user asks to **edit** an existing image (this skill is text-to-image only today)
123
+ - The user wants a **diagram / chart** with specific data — use a charting library (matplotlib, mermaid, etc.) instead; image gen is for illustrations, not data viz
124
+ - The user asks for **screenshots** of real software — use the browser tool
125
+
126
+ ## Future modalities
127
+
128
+ The same `/api/media/` namespace will gain `video` and `audio` endpoints. The pattern is identical: the user configures `type=video` / `type=audio` models in settings, this skill (or its successor) calls the matching endpoint.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "base64"
5
+ require "securerandom"
6
+
7
+ module Clacky
8
+ module Media
9
+ # Abstract base for media (image / video / audio) generation providers.
10
+ #
11
+ # Subclasses implement #generate_image (and later #generate_video,
12
+ # #generate_audio). The base class supplies the uniform success/error
13
+ # response shape and the on-disk persistence helper, mirroring the
14
+ # design used by Hermes' image_gen_provider so the surface stays
15
+ # learnable across modalities.
16
+ class Base
17
+ # @param model_entry [Hash] one entry from AgentConfig#models — must
18
+ # include "model", "base_url", "api_key" keys.
19
+ def initialize(model_entry)
20
+ @model_entry = model_entry
21
+ @model = model_entry["model"]
22
+ @base_url = model_entry["base_url"]
23
+ @api_key = model_entry["api_key"]
24
+ end
25
+
26
+ # @return [Hash] either success_response(...) or error_response(...)
27
+ def generate_image(prompt:, aspect_ratio: "landscape", output_dir: nil, **_kwargs)
28
+ raise NotImplementedError, "#{self.class.name} must implement #generate_image"
29
+ end
30
+
31
+ # Persist a base64-encoded image under <output_dir>/assets/generated/.
32
+ # Returns the absolute path on disk.
33
+ private def save_b64_image(b64_data, output_dir:, prefix: "img", extension: "png")
34
+ target_dir = File.join(output_dir, "assets", "generated")
35
+ FileUtils.mkdir_p(target_dir)
36
+ ts = Time.now.strftime("%Y%m%d_%H%M%S")
37
+ short = SecureRandom.hex(4)
38
+ path = File.join(target_dir, "#{prefix}_#{ts}_#{short}.#{extension}")
39
+ File.binwrite(path, Base64.decode64(b64_data))
40
+ path
41
+ end
42
+
43
+ private def success_response(image:, prompt:, aspect_ratio:, provider:, extra: {})
44
+ {
45
+ "success" => true,
46
+ "image" => image,
47
+ "model" => @model,
48
+ "prompt" => prompt,
49
+ "aspect_ratio" => aspect_ratio,
50
+ "provider" => provider
51
+ }.merge(extra)
52
+ end
53
+
54
+ private def error_response(error:, error_type: "provider_error", provider: "", prompt: "", aspect_ratio: "landscape")
55
+ {
56
+ "success" => false,
57
+ "image" => nil,
58
+ "error" => error,
59
+ "error_type" => error_type,
60
+ "model" => @model,
61
+ "prompt" => prompt,
62
+ "aspect_ratio" => aspect_ratio,
63
+ "provider" => provider
64
+ }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require_relative "base"
6
+
7
+ module Clacky
8
+ module Media
9
+ # Native Google Gemini image generation adapter.
10
+ #
11
+ # Reserved for users who configure a direct Google AI Studio base_url
12
+ # (e.g. https://generativelanguage.googleapis.com) with a raw Google API
13
+ # key. The official endpoints are:
14
+ # POST /v1beta/models/<model>:generateContent — image-out via Gemini
15
+ # POST /v1beta/models/<model>:predict — Imagen
16
+ # with x-goog-api-key auth, contents[].parts[] request schema, and
17
+ # candidates[].content.parts[].inlineData response schema. Completely
18
+ # different from the OpenAI /v1/images/generations contract.
19
+ #
20
+ # Today every shipping path (openclacky gateway, OpenRouter) wraps Gemini
21
+ # behind an OpenAI-compatible facade, so OpenAICompat handles them and
22
+ # this class is intentionally a stub. We surface a clear error rather
23
+ # than silently 404 against Google's actual host.
24
+ class Gemini < Base
25
+ def generate_image(prompt:, aspect_ratio: "landscape", output_dir: nil, **_kwargs)
26
+ error_response(
27
+ error: "Direct Google AI Studio (generativelanguage.googleapis.com) image generation is not yet supported. Use the openclacky or OpenRouter gateway instead — set base_url to https://api.openclacky.com or https://openrouter.ai/api/v1 and pick a Gemini image model (e.g. or-gemini-3-pro-image, google/gemini-3-pro-image-preview).",
28
+ error_type: "not_implemented",
29
+ provider: "gemini-direct",
30
+ prompt: prompt,
31
+ aspect_ratio: aspect_ratio
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai_compat"
4
+ require_relative "gemini"
5
+
6
+ module Clacky
7
+ module Media
8
+ # Top-level dispatcher: takes an AgentConfig and a request, picks the
9
+ # right provider class based on the configured image model's base_url,
10
+ # and delegates.
11
+ #
12
+ # Adding a new modality (video / audio) means:
13
+ # 1. add a generate_<modality> method here that resolves the correct
14
+ # type=<modality> entry and class
15
+ # 2. add a provider class under lib/clacky/media/ implementing the call
16
+ class Generator
17
+ # Hosts that speak the native Google AI Studio API instead of an
18
+ # OpenAI-compatible facade. Matched as a substring against the
19
+ # configured base_url so any regional / staging variant is caught.
20
+ GOOGLE_NATIVE_HOSTS = [
21
+ "generativelanguage.googleapis.com",
22
+ "aiplatform.googleapis.com"
23
+ ].freeze
24
+
25
+ # @param agent_config [Clacky::AgentConfig]
26
+ def initialize(agent_config)
27
+ @agent_config = agent_config
28
+ end
29
+
30
+ # @return [Hash, nil] the type=image model entry, or nil if not configured
31
+ def image_model_entry
32
+ @agent_config.find_model_by_type("image")
33
+ end
34
+
35
+ def generate_image(prompt:, aspect_ratio: "landscape", output_dir: nil, **kwargs)
36
+ entry = image_model_entry
37
+ if entry.nil?
38
+ return {
39
+ "success" => false,
40
+ "image" => nil,
41
+ "error" => "No image model configured. Add a model with type=image in settings.",
42
+ "error_type" => "not_configured",
43
+ "provider" => "",
44
+ "model" => "",
45
+ "prompt" => prompt
46
+ }
47
+ end
48
+
49
+ provider = build_provider_for(entry)
50
+ provider.generate_image(
51
+ prompt: prompt,
52
+ aspect_ratio: aspect_ratio,
53
+ output_dir: output_dir,
54
+ **kwargs
55
+ )
56
+ end
57
+
58
+ # Pick the adapter class for a media model entry.
59
+ #
60
+ # Routing rules:
61
+ # • base_url points directly at a Google AI Studio host → Gemini
62
+ # (native /v1beta/models/<m>:generateContent schema).
63
+ # • everything else → OpenAICompat. This covers OpenAI itself, the
64
+ # openclacky gateway, OpenRouter, and any third-party proxy that
65
+ # re-exposes Gemini / Imagen / DALL-E behind /v1/images/generations.
66
+ # OpenAICompat#generate_image branches internally on model id to
67
+ # drop OpenAI-only params (size) when talking to Gemini families.
68
+ private def build_provider_for(entry)
69
+ url = entry["base_url"].to_s
70
+ if GOOGLE_NATIVE_HOSTS.any? { |host| url.include?(host) }
71
+ Gemini.new(entry)
72
+ else
73
+ OpenAICompat.new(entry)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require_relative "base"
6
+
7
+ module Clacky
8
+ module Media
9
+ # OpenAI-compatible image generation provider.
10
+ #
11
+ # Talks to POST <base_url>/images/generations with the standard OpenAI
12
+ # request shape. Handles three providers under one class because they
13
+ # all expose the same endpoint: OpenAI, OpenRouter, and the openclacky
14
+ # platform gateway. Provider-specific quirks (model id naming, billing)
15
+ # live in PRESETS, not here.
16
+ class OpenAICompat < Base
17
+ ASPECT_TO_SIZE = {
18
+ "landscape" => "1536x1024",
19
+ "square" => "1024x1024",
20
+ "portrait" => "1024x1536"
21
+ }.freeze
22
+
23
+ DEFAULT_ASPECT = "landscape"
24
+
25
+ def generate_image(prompt:, aspect_ratio: DEFAULT_ASPECT, output_dir: nil, n: 1, **_kwargs)
26
+ provider_id = Clacky::Providers.find_by_base_url(@base_url) || "custom"
27
+ aspect = ASPECT_TO_SIZE.key?(aspect_ratio) ? aspect_ratio : DEFAULT_ASPECT
28
+ size = ASPECT_TO_SIZE[aspect]
29
+
30
+ if prompt.to_s.strip.empty?
31
+ return error_response(
32
+ error: "Prompt is required and must be a non-empty string",
33
+ error_type: "invalid_argument",
34
+ provider: provider_id,
35
+ aspect_ratio: aspect
36
+ )
37
+ end
38
+
39
+ if @api_key.to_s.empty?
40
+ return error_response(
41
+ error: "api_key not configured for image model '#{@model}'",
42
+ error_type: "auth_required",
43
+ provider: provider_id,
44
+ prompt: prompt,
45
+ aspect_ratio: aspect
46
+ )
47
+ end
48
+
49
+ payload = { model: @model, n: n }
50
+ if gemini_family?(@model)
51
+ # Gemini image models (routed via openclacky / openrouter gateway)
52
+ # don't accept the OpenAI `size` parameter — they infer aspect from
53
+ # the prompt text. Embedding a hint keeps the user's aspect choice
54
+ # honoured without breaking the gateway request validator.
55
+ payload[:prompt] = "#{prompt}\n\n[aspect: #{aspect}]"
56
+ else
57
+ payload[:prompt] = prompt
58
+ payload[:size] = size
59
+ end
60
+
61
+ begin
62
+ response = connection.post("images/generations") do |req|
63
+ req.headers["Content-Type"] = "application/json"
64
+ req.headers["Authorization"] = "Bearer #{@api_key}"
65
+ req.body = JSON.generate(payload)
66
+ end
67
+ rescue Faraday::Error => e
68
+ return error_response(
69
+ error: "HTTP request failed: #{e.message}",
70
+ error_type: "network_error",
71
+ provider: provider_id,
72
+ prompt: prompt,
73
+ aspect_ratio: aspect
74
+ )
75
+ end
76
+
77
+ unless response.success?
78
+ return error_response(
79
+ error: "Upstream #{response.status}: #{truncate(response.body, 500)}",
80
+ error_type: "api_error",
81
+ provider: provider_id,
82
+ prompt: prompt,
83
+ aspect_ratio: aspect
84
+ )
85
+ end
86
+
87
+ body = parse_json(response.body)
88
+ return error_response(
89
+ error: "Invalid JSON response from upstream",
90
+ error_type: "invalid_response",
91
+ provider: provider_id,
92
+ prompt: prompt,
93
+ aspect_ratio: aspect
94
+ ) unless body.is_a?(Hash)
95
+
96
+ data = body["data"] || []
97
+ first = data.first
98
+ if first.nil?
99
+ return error_response(
100
+ error: "Upstream returned no image data",
101
+ error_type: "empty_response",
102
+ provider: provider_id,
103
+ prompt: prompt,
104
+ aspect_ratio: aspect
105
+ )
106
+ end
107
+
108
+ image_ref =
109
+ if first["b64_json"]
110
+ save_b64_image(first["b64_json"], output_dir: output_dir || Dir.pwd, prefix: "img")
111
+ elsif first["url"]
112
+ first["url"]
113
+ end
114
+
115
+ if image_ref.nil?
116
+ return error_response(
117
+ error: "Response contained neither b64_json nor url",
118
+ error_type: "empty_response",
119
+ provider: provider_id,
120
+ prompt: prompt,
121
+ aspect_ratio: aspect
122
+ )
123
+ end
124
+
125
+ success_response(
126
+ image: image_ref,
127
+ prompt: prompt,
128
+ aspect_ratio: aspect,
129
+ provider: provider_id,
130
+ extra: {
131
+ "size" => size,
132
+ "usage" => body["usage"],
133
+ "cost_usd" => body["cost_usd"]
134
+ }.compact
135
+ )
136
+ end
137
+
138
+ private def connection
139
+ Faraday.new(url: normalized_base_url) do |f|
140
+ f.options.timeout = 240
141
+ f.options.open_timeout = 10
142
+ end
143
+ end
144
+
145
+ private def gemini_family?(model_name)
146
+ model_name.to_s.match?(/gemini|imagen/i)
147
+ end
148
+
149
+ # base_url is taken verbatim from PRESETS (each provider already
150
+ # includes the API version segment when needed). We only ensure a
151
+ # trailing slash so Faraday's relative-path join behaves.
152
+ private def normalized_base_url
153
+ "#{@base_url.to_s.chomp("/")}/"
154
+ end
155
+
156
+ private def parse_json(body)
157
+ JSON.parse(body)
158
+ rescue JSON::ParserError
159
+ nil
160
+ end
161
+
162
+ private def truncate(str, max)
163
+ s = str.to_s
164
+ s.length > max ? "#{s[0, max]}..." : s
165
+ end
166
+ end
167
+ end
168
+ end