Repository navigation
Expand file tree
/
Copy pathchatv3_media.go
More file actions
310 lines (296 loc) · 14.1 KB
/
Copy pathchatv3_media.go
File metadata and controls
310 lines (296 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
package main
// chatv3_media.go is Decision 5 of docs/MULTIMODAL.md's v3 revision: ONE KNOB
// PER MODALITY, resolved AT USE TIME.
//
// The double-knob era is what this closes. The settings sheet carried a row for
// the drawing model that nothing read, beside a role pin that the engine obeyed
// and no surface showed; the looking row and roles.vision were two names for
// one question and disagreed on a fresh profile. So there is one resolver, it
// is asked at the moment something draws rather than at boot, and it walks one
// documented ladder:
//
// 1. THE SETTINGS SLOT — the row a person actually opened and chose, read
// from the profile at the moment of the call (config.MediaSlotModelAt, and
// config.VisionModelAt for looking). A choice made in the sheet is live in
// the running session, not on the next launch.
// 2. THE ROLE PIN — roles.<name>, the operator's free-text second rung.
// 3. THE CATALOG — the best model the catalog advertises publishing the
// capability (config.CandidateMediaModel).
// 4. THE CURATED NAME — what this build remembers (config.FallbackMediaModel),
// which is what makes a machine that has never opened settings still draw,
// speak and film out of the box.
//
// EVERY RUNG IS CAPABILITY-CHECKED against the catalog's published modalities.
// A slot or a pin naming a model that cannot do the job is passed over with one
// log line and the ladder carries on, so a renamed slug degrades to the best
// available model instead of arriving at a provider as a 404. This is the
// promise Decision 3 made and the first wiring never kept.
//
// THE EIGHT WORDS the resolver answers to, and what each one asks the catalog:
//
// image output image — the model that draws
// speech output speech — the model that speaks (the catalog aliases
// the provider's broad "audio" onto this)
// music output music — the model that composes. It is its own word
// and not a reading of speech: the two ride one
// endpoint and are two different models, and a
// TTS row that publishes only "speech" is
// correctly refused here
// video output video — the model that films
// vision input image — the model that looks at a picture and
// answers in words
// transcribe input audio — the ear: sound in, words back, on the
// dedicated transcription endpoint
// listen input audio — a CHAT model that can be handed a sound and
// talk about it
// watch input video — a CHAT model that can be handed a film
//
// The first five are session.Config.MediaModel's own contract; the last three
// are the perception belt's (the media/hear lane). The four INPUT words all
// demand text back as well, because a model that takes sound and answers in
// sound is a speaker rather than a listener.
import (
"context"
"log"
"strings"
"github.com/Agent-Field/codeaf/internal/catalog"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/modelsource"
"github.com/Agent-Field/codeaf/internal/roles"
"github.com/Agent-Field/codeaf/internal/session"
)
// v3CatalogForModel resolves published capabilities from the service that
// serves model. A listing-less service gets an empty catalog without a fetch,
// which keeps every media verb absent. A listing-capable direct service gets
// its own source-scoped lazy catalog rather than borrowing OpenRouter's rows.
//
// The third answer is WHETHER THIS SERVICE CAN MAKE MEDIA AT ALL, which is not
// the same question as whether it publishes a model list. The default service
// can: its curated fallback names are its own ids and its catalog carries the
// rows. A DIRECT service can only if its own catalog says so — the fallbacks
// are OpenRouter's ids (config.FallbackMediaModel) and a direct vendor has
// never heard of them, so arming the pair on a listing alone puts four verbs on
// the belt that cannot succeed and re-opens the hole the empty catalog closed.
// A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN.
func v3CatalogForModel(ctx context.Context, settings config.Config, model string, defaults *catalog.Catalog) (*catalog.Catalog, string, bool) {
set := settings.Sources.OrDefault(settings.APIKey, settings.BaseURL)
service, bare := set.For(model)
if strings.EqualFold(service.Source.ID, modelsource.DefaultID) {
return defaults, bare, true
}
if service.Source.Listing == modelsource.ListingNone {
return &catalog.Catalog{}, bare, false
}
direct := catalog.LoadLazy(ctx, config.CatalogOptionsFor(service, settings.ProfileDir))
return direct, bare, v3ServesMedia(direct)
}
// v3ServesMedia is whether this catalog PUBLISHES a row that makes something,
// asked without waiting: a catalog still warming answers no, and the launch
// leaves the verbs off rather than arming a hand whose model it cannot name.
// The next launch arms them once the rows are on disk, which is the same trade
// every other question about a warming catalog already makes.
func v3ServesMedia(models *catalog.Catalog) bool {
if models == nil {
return false
}
available := models.SnapshotNow()
for _, modality := range []string{"image", "speech", "music", "video"} {
if len(available.ModelsWithOutput(modality)) > 0 {
return true
}
}
return false
}
// v3MediaSlot is the settings slot each modality's first rung reads. Vision is
// the one that is not a capability slot: "looking" has always been its own
// registry row (config.KeyVisionModel), and UNIFYING THE DOUBLE KNOB means the
// resolver reads exactly that row rather than inventing a sixth slot beside it.
// The three perception words share the voice slot or have no slot at all, which
// is stated by their absence here.
var v3MediaSlot = map[string]string{
"image": "image",
"speech": "speech",
"music": "music",
"video": "video",
"transcribe": "voice",
}
// v3MediaPin is the role whose pin is the second rung. Four of the eight have
// one; the perception words and MUSIC have none, and a pin nobody can write is a
// rung that would only ever be skipped — internal/roles has no music role, so
// composing resolves on its slot, the catalog and the curated name alone.
var v3MediaPin = map[string]roles.Role{
"image": roles.RoleImageGen,
"speech": roles.RoleSpeech,
"video": roles.RoleVideo,
"vision": roles.RoleVision,
}
// v3MediaVerb is what the modality is called in a log line — the plain word,
// because a line a person reads while wondering why their pin was ignored is a
// person-facing string.
var v3MediaVerb = map[string]string{
"image": "draw",
"speech": "speak",
"music": "compose",
"video": "film",
"vision": "see",
"transcribe": "transcribe",
"listen": "listen",
"watch": "watch",
}
// v3MediaModel is session.Config.MediaModel: one modality word in, one slug
// out, or "" when this install has no capable model for it at all.
//
// The empty answer is load-bearing and never a failure — session.Config states
// the law: a modality with no model keeps that modality's tools OFF THE BELT,
// so the model does not have a verb it would only be refused on (CLAUDE.md's
// absent-not-broken law).
//
// THE RESOLVER ALSO RUNS WHILE THE INITIAL TOOL BELT IS BUILT. It therefore
// reads a nonblocking snapshot: current rows, cached capabilities, or the
// default service's curated fallback. Later calls take a fresh snapshot, so
// model discovery can improve the answer without holding the first frame.
//
// profileDir and source are the two rungs the catalog cannot answer, and they
// are read at CALL time rather than closed over as values: a person who picks a
// drawing model in the settings sheet expects the next picture to come from it,
// not the next launch.
func v3AutomaticMediaModel(models *catalog.Catalog, profileDir string, source roles.Source) func(string) string {
return func(modality string) string {
modality = strings.ToLower(strings.TrimSpace(modality))
if models == nil || v3MediaVerb[modality] == "" {
return ""
}
available := models.SnapshotNow()
// able is one rung: a name, checked, and either taken or passed over
// out loud. The log line names the rung because the three rungs fail
// for different reasons — a slot is a person's own stale choice, a pin
// is an operator's, and a curated name is this build being out of date.
able := func(rung, id string) string {
if id = strings.TrimSpace(id); id == "" {
return ""
}
if !v3MediaCapable(available, modality, id) {
log.Printf("media: the %s %s names %s, which cannot %s — passing over it",
modality, rung, id, v3MediaVerb[modality])
return ""
}
return id
}
if slot, ok := v3MediaSlot[modality]; ok {
if id := able("slot", config.MediaSlotModelAt(profileDir, slot)); id != "" {
return id
}
}
if modality == "vision" {
// The looking row, which is the vision slot under its own name.
if id := able("slot", config.VisionModelAt(profileDir)); id != "" {
return id
}
}
if role, pinnable := v3MediaPin[modality]; pinnable {
if pinned, set := roles.Pinned(source, role); set {
if id := able("pin", pinned); id != "" {
return id
}
}
}
if id := able("catalog", config.CandidateMediaModel(available, modality)); id != "" {
return id
}
return able("fallback", config.FallbackMediaModel(modality))
}
}
// v3MediaPick is session.Config.MediaPick: the just-in-time choice beside the
// defaults ladder. It answers the model's own word for what it wants — a slug,
// a fragment, or "best" — from the SAME catalog the defaults ladder reads, so
// a picked model is capability-checked and modality-scoped exactly as a
// default is: a word that names a speech model from the image verb is refused
// by naming what it actually makes, and a word that matches nothing says so.
func v3MediaPick(models *catalog.Catalog) func(string, string) (string, error) {
if models == nil {
return nil
}
return func(modality, word string) (string, error) {
return config.ResolveMediaModel(models, modality, word)
}
}
// v3MediaClient is session.Config.Media: the one client that reaches every
// generation endpoint, or nil when this install cannot build one.
//
// THE TWO-LINE DANCE IS GO'S AND NOT A CHOICE, the same one v3Connections does
// and for the same reason: a nil *provider.MediaClient assigned straight into
// the interface field is a NON-nil interface holding nothing, and session's
// absence law is a plain nil check. Without it, a build that could not make a
// client would put every generation tool on the belt and fail each one on its
// first call — the belt that lies, which is the thing the law exists to stop.
//
// NO KEY IS ABSENCE, NOT A FAULT, AND ABSENCE SAYS NOTHING. A first launch
// builds its conversation before setup has asked for a key, so this door is
// reached keyless on every new install; the line it used to log landed on the
// person's terminal just before the surface took it, and was the first thing
// they read after quitting (the fresh-install check of 2026-09-25). The key
// asked about is the one the client would carry — [config.Config.ClientConfig]
// resolves it the way [config.Config.MediaClient] does — and only a failure
// with a key in hand is a fault worth a line.
func v3MediaClient(settings config.Config) session.MediaGenerator {
if strings.TrimSpace(settings.ClientConfig(settings.Model).APIKey) == "" {
return nil
}
client, err := settings.MediaClient()
if err != nil || client == nil {
if err != nil {
log.Printf("media: no generation endpoint on this install: %v", err)
}
return nil
}
return client
}
// v3MediaCapable is the published-facts test one modality applies to one slug.
//
// It asks the CATALOG and nothing else — no id patterns, no vendor guesses —
// for the reason the vision gate does: a model that says it draws draws, and
// anything else is a "no" this door can defend to somebody whose pin was
// ignored. A slug the catalog has never heard of fails every question here,
// which is the honest reading of "nobody can vouch for this".
func v3MediaCapable(models *catalog.Catalog, modality, id string) bool {
switch modality {
case "image":
return models.Supports(id, "output", "image")
case "speech":
return models.Supports(id, "output", "speech")
case "music":
// A SEPARATE QUESTION FROM SPEECH, and the catalog keeps them separate:
// internal/catalog's hasModality aliases the provider's broad "audio"
// onto both words, but a row that publishes only "speech" answers no
// here — which is the honest reading of a TTS model asked to compose.
return models.Supports(id, "output", "music")
case "video":
return models.Supports(id, "output", "video")
case "vision":
return models.Supports(id, "input", "image") && v3MediaAnswersInWords(models, id)
case "transcribe":
// The ear is the one input word that does NOT demand a conversation
// back: a transcription row answers in text, or in the "transcription"
// modality a provider that uses that vocabulary publishes, and neither
// of them is a model you can talk to.
return models.Supports(id, "input", "audio") &&
(models.Supports(id, "output", "text") || models.Supports(id, "output", "transcription"))
case "listen":
return models.Supports(id, "input", "audio") && v3MediaAnswersInWords(models, id)
case "watch":
return models.Supports(id, "input", "video") && v3MediaAnswersInWords(models, id)
}
return false
}
// v3MediaAnswersInWords is the second half of every perception question: the
// model has to answer in text AND IN NOTHING ELSE. A drawing model that captions
// what it paints publishes ["image","text"] out and would sail through a rule
// that only asked whether text was in the list — and it cannot answer "what is
// in this photo", which is the only thing a perception slot is ever asked.
func v3MediaAnswersInWords(models *catalog.Catalog, id string) bool {
row, known := models.Model(id)
if !known {
return false
}
return v3AnswersText(row.OutputModalities) && len(row.OutputModalities) > 0
}