genblaze-replicate 0.3.2__tar.gz → 0.3.3__tar.gz

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.
@@ -54,7 +54,12 @@ ehthumbs.db
54
54
  !.env.example
55
55
  *.pem
56
56
  *.key
57
- credentials.json
57
+ credentials*
58
+ *.credentials
59
+ *_rsa
60
+ *.p12
61
+ *.pfx
62
+ *.keystore
58
63
 
59
64
  # Jupyter
60
65
  .ipynb_checkpoints/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: genblaze-replicate
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Replicate provider adapter for genblaze
5
5
  Project-URL: Homepage, https://github.com/backblaze-labs/genblaze
6
6
  Project-URL: Documentation, https://github.com/backblaze-labs/genblaze
@@ -48,7 +48,11 @@ Supports any model hosted on Replicate — including:
48
48
  - **Video** — text-to-video and image-to-video models on Replicate
49
49
  - **Audio** — music and speech models on Replicate
50
50
 
51
- Use the full `owner/model` slug from https://replicate.com/explore.
51
+ Use the full `owner/model` slug from https://replicate.com/explore — official
52
+ and community models alike. `submit()` runs each via the right Replicate
53
+ endpoint: community models resolve to their latest published version (cached
54
+ per-slug), official models run version-less. Pin an exact community version
55
+ with `owner/model:<version-hash>`.
52
56
 
53
57
  ## Install
54
58
 
@@ -21,7 +21,11 @@ Supports any model hosted on Replicate — including:
21
21
  - **Video** — text-to-video and image-to-video models on Replicate
22
22
  - **Audio** — music and speech models on Replicate
23
23
 
24
- Use the full `owner/model` slug from https://replicate.com/explore.
24
+ Use the full `owner/model` slug from https://replicate.com/explore — official
25
+ and community models alike. `submit()` runs each via the right Replicate
26
+ endpoint: community models resolve to their latest published version (cached
27
+ per-slug), official models run version-less. Pin an exact community version
28
+ with `owner/model:<version-hash>`.
25
29
 
26
30
  ## Install
27
31
 
@@ -72,6 +72,12 @@ _FALLBACK_SPEC = ModelSpec(
72
72
  input_mapping=route_by_media_type({"image": "image", "video": "video", "audio": "audio"}),
73
73
  )
74
74
 
75
+ # Sentinel distinguishing "slug not yet resolved" from "slug resolved to no
76
+ # version" in ``_version_cache``. The latter caches ``None`` — a real result
77
+ # meaning "official model, submit via the ``model=`` path" — so a plain
78
+ # ``dict.get(slug)`` can't tell a miss from a cached official model.
79
+ _VERSION_UNRESOLVED: Any = object()
80
+
75
81
 
76
82
  class ReplicateProvider(BaseProvider):
77
83
  """Provider adapter for Replicate (replicate.com)."""
@@ -131,6 +137,19 @@ class ReplicateProvider(BaseProvider):
131
137
  self._validation_cache: dict[str, tuple[float, ValidationResult]] = {}
132
138
  self._validation_lock = threading.RLock()
133
139
  self._validation_ttl: float = DEFAULT_TTL_SECONDS
140
+ # Per-slug submit-time resolution cache: ``owner/name`` → version
141
+ # hash, or ``None`` for official/versionless models that run via the
142
+ # ``model=`` path (see ``_resolve_version``). Deliberately *not*
143
+ # TTL-bounded like ``_validation_cache``: genblaze runs a process per
144
+ # pipeline, so pinning the slug→version resolved at first sight gives
145
+ # version consistency across a run (call ``validate_model(refresh=
146
+ # True)`` to re-resolve a newly-published *latest* version). Kept as a
147
+ # small purpose-built dict rather than reusing the base ``_probe_cache``
148
+ # machinery — the value is a bare hash, not a ``LiveProbeResult``,
149
+ # resolution is a cheap idempotent token-free GET, and it is seeded for
150
+ # free from ``validate_model``'s probe. Guarded by the same lock as the
151
+ # validation cache since both are populated from one ``models.get()``.
152
+ self._version_cache: dict[str, str | None] = {}
134
153
  # Wire a discovery cache for the first-page snapshot. The fetcher
135
154
  # closes over ``self`` so it picks up the (possibly-late-bound)
136
155
  # ``self._client``.
@@ -207,8 +226,14 @@ class ReplicateProvider(BaseProvider):
207
226
  return base_result
208
227
 
209
228
  if refresh:
229
+ slug, _ = self._split_model_id(model_id)
210
230
  with self._validation_lock:
211
231
  self._validation_cache.pop(model_id, None)
232
+ # Keep the version cache in lockstep — a caller explicitly
233
+ # asking to refresh a slug's validation almost always wants
234
+ # a fresh "latest_version" too (e.g. the model owner just
235
+ # published a new version).
236
+ self._version_cache.pop(slug, None)
212
237
  else:
213
238
  cached = self._lookup_validation_cache(model_id)
214
239
  if cached is not None:
@@ -242,9 +267,13 @@ class ReplicateProvider(BaseProvider):
242
267
  * Other errors → ``UNKNOWN_PERMISSIVE`` (we couldn't verify; let
243
268
  the upstream call surface the real error if there is one).
244
269
  """
270
+ # Strip any inline ``:version`` — Replicate's model-existence GET
271
+ # (/v1/models/{owner}/{name}) only accepts the bare slug; a compound
272
+ # ``owner/name:hash`` key 404s even when the model exists.
273
+ slug, _ = self._split_model_id(model_id)
245
274
  try:
246
275
  client = self._get_client()
247
- client.models.get(model_id)
276
+ model = client.models.get(slug)
248
277
  except Exception as exc:
249
278
  err_code = map_replicate_error(exc)
250
279
  if err_code is ProviderErrorCode.MODEL_ERROR:
@@ -258,11 +287,78 @@ class ReplicateProvider(BaseProvider):
258
287
  exc,
259
288
  )
260
289
  return ValidationResult.unknown_permissive(detail=f"probe inconclusive: {exc}")
290
+ # This probe already fetched the model object that ``submit()`` needs
291
+ # to resolve a version — seed the version cache so a Pipeline run
292
+ # (preflight validate_model, then submit) never fetches it twice.
293
+ self._cache_model_version(slug, model)
261
294
  return ValidationResult.ok_authoritative(
262
295
  ValidationSource.PROBE,
263
296
  detail="confirmed via /v1/models/{owner}/{name}",
264
297
  )
265
298
 
299
+ @staticmethod
300
+ def _split_model_id(model_id: str) -> tuple[str, str | None]:
301
+ """Split ``owner/name[:version-hash]`` into ``(slug, inline_version)``.
302
+
303
+ Replicate slugs never contain a bare ``:`` themselves, so the first
304
+ colon (if any) unambiguously separates an inline-pinned version hash.
305
+ Shared by every call site that talks to ``/v1/models/{owner}/{name}``
306
+ (which rejects the compound form) or that needs a version hash.
307
+ """
308
+ slug, _, inline_version = model_id.partition(":")
309
+ return slug, (inline_version or None)
310
+
311
+ def _cache_model_version(self, slug: str, model: Any) -> str | None:
312
+ """Extract and cache ``model.latest_version.id`` for ``slug``.
313
+
314
+ Shared by ``_authoritative_lookup`` (validate_model's per-slug probe)
315
+ and ``_resolve_version`` (submit-time resolution) so a single
316
+ ``models.get()`` round-trip seeds both caches. ``slug`` must already
317
+ be stripped of any inline version (see ``_split_model_id``). Returns —
318
+ and caches — the version hash, or ``None`` for official/versionless
319
+ models. Caching the ``None`` result too means a fan-out of submits over
320
+ an official model probes once rather than re-resolving per prediction.
321
+ """
322
+ latest_version = getattr(model, "latest_version", None)
323
+ version_id = getattr(latest_version, "id", None) or None
324
+ with self._validation_lock:
325
+ self._version_cache[slug] = version_id
326
+ return version_id
327
+
328
+ def _resolve_version(self, model_id: str) -> str | None:
329
+ """Resolve ``model_id`` to a version hash for prediction submission.
330
+
331
+ Replicate has two kinds of runnable models submitted via two different
332
+ endpoints, so resolution returns either a hash or ``None`` (#109):
333
+
334
+ * **Community models** carry published versions and 404 on the
335
+ ``predictions.create(model=<slug>)`` path — they must be run via
336
+ ``predictions.create(version=<hash>)``. Returns their hash.
337
+ * **Official models** (e.g. ``black-forest-labs/flux-schnell``) expose
338
+ *no* version and can only be run via the ``model=<slug>`` path.
339
+ Returns ``None`` to tell ``submit()`` to take that fallback.
340
+
341
+ Resolution order:
342
+
343
+ 1. Inline version (``owner/name:hash``) — already a hash; no network.
344
+ 2. Cached resolution from a prior ``submit()``/``validate_model()`` for
345
+ this slug (``None`` is a valid cached "official model" result, hence
346
+ the ``_VERSION_UNRESOLVED`` sentinel to detect a genuine miss).
347
+ 3. Otherwise ``client.models.get(slug)`` — one round-trip, cached
348
+ per-slug (hash or ``None``) thereafter.
349
+ """
350
+ slug, inline_version = self._split_model_id(model_id)
351
+ if inline_version:
352
+ return inline_version
353
+
354
+ cached = self._version_cache.get(slug, _VERSION_UNRESOLVED)
355
+ if cached is not _VERSION_UNRESOLVED:
356
+ return cached
357
+
358
+ client = self._get_client()
359
+ model = client.models.get(slug)
360
+ return self._cache_model_version(slug, model)
361
+
266
362
  def _get_client(self):
267
363
  if self._client is None:
268
364
  try:
@@ -287,11 +383,25 @@ class ReplicateProvider(BaseProvider):
287
383
  client = self._get_client()
288
384
  try:
289
385
  input_params = self.prepare_payload(step)
290
- prediction = client.predictions.create(
291
- model=step.model,
292
- input=input_params,
293
- )
386
+ version = self._resolve_version(step.model)
387
+ if version is not None:
388
+ # Community model — run the resolved version via /v1/predictions.
389
+ prediction = client.predictions.create(
390
+ version=version,
391
+ input=input_params,
392
+ )
393
+ else:
394
+ # Official/versionless model — only runnable via the model=
395
+ # path (/v1/models/{owner}/{name}/predictions). Pass the bare
396
+ # slug; that endpoint rejects a compound ``owner/name:hash``.
397
+ slug, _ = self._split_model_id(step.model)
398
+ prediction = client.predictions.create(
399
+ model=slug,
400
+ input=input_params,
401
+ )
294
402
  return prediction.id
403
+ except ProviderError:
404
+ raise
295
405
  except Exception as exc:
296
406
  raise ProviderError(
297
407
  f"Replicate submit failed: {exc}",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "genblaze-replicate"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Replicate provider adapter for genblaze"
9
9
  authors = [{name = "Jeronimo De Leon", email = "jdeleon@backblaze.com"}]
10
10
  readme = "README.md"
@@ -34,6 +34,16 @@ def _make_step(**kwargs) -> Step:
34
34
  return Step(**defaults)
35
35
 
36
36
 
37
+ def _make_model_with_version(version_id: str | None):
38
+ """A fake ``replicate.model.Model`` exposing a concrete ``latest_version``."""
39
+ model = MagicMock()
40
+ if version_id is None:
41
+ model.latest_version = None
42
+ else:
43
+ model.latest_version = MagicMock(id=version_id)
44
+ return model
45
+
46
+
37
47
  # --- map_replicate_error tests ---
38
48
 
39
49
 
@@ -76,6 +86,7 @@ def test_map_error_accepts_exception():
76
86
  def test_submit_sends_prompt_and_params():
77
87
  provider = ReplicateProvider(api_token="test-token")
78
88
  mock_client = MagicMock()
89
+ mock_client.models.get.return_value = _make_model_with_version("v-resolved")
79
90
  mock_client.predictions.create.return_value = FakePrediction()
80
91
  provider._client = mock_client
81
92
 
@@ -83,7 +94,7 @@ def test_submit_sends_prompt_and_params():
83
94
  result = provider.submit(step)
84
95
 
85
96
  mock_client.predictions.create.assert_called_once_with(
86
- model="test/model",
97
+ version="v-resolved",
87
98
  input={"width": 1024, "prompt": "a cat"},
88
99
  )
89
100
  assert result == "pred-abc123"
@@ -92,6 +103,7 @@ def test_submit_sends_prompt_and_params():
92
103
  def test_submit_includes_negative_prompt():
93
104
  provider = ReplicateProvider(api_token="test-token")
94
105
  mock_client = MagicMock()
106
+ mock_client.models.get.return_value = _make_model_with_version("v-resolved")
95
107
  mock_client.predictions.create.return_value = FakePrediction()
96
108
  provider._client = mock_client
97
109
 
@@ -102,6 +114,168 @@ def test_submit_includes_negative_prompt():
102
114
  assert call_input["negative_prompt"] == "blurry"
103
115
 
104
116
 
117
+ # --- version resolution (community-model 404 fix, #109) ---
118
+
119
+
120
+ def test_submit_resolves_community_model_slug_to_version():
121
+ """A bare ``owner/name`` slug (the common case — community models) must
122
+ resolve to a version hash and submit via ``version=``, not ``model=``.
123
+ ``predictions.create(model=...)`` only resolves official models and 404s
124
+ for the vast majority of Replicate's public catalog (#109)."""
125
+ provider = ReplicateProvider(api_token="test-token")
126
+ mock_client = MagicMock()
127
+ mock_client.models.get.return_value = _make_model_with_version("abc123hash")
128
+ mock_client.predictions.create.return_value = FakePrediction()
129
+ provider._client = mock_client
130
+
131
+ step = _make_step(model="sczhou/codeformer")
132
+ provider.submit(step)
133
+
134
+ mock_client.models.get.assert_called_once_with("sczhou/codeformer")
135
+ mock_client.predictions.create.assert_called_once_with(
136
+ version="abc123hash",
137
+ input={"prompt": "a cat"},
138
+ )
139
+
140
+
141
+ def test_submit_uses_inline_version_without_network_call():
142
+ """``owner/name:hash`` carries the version inline — no ``models.get()``
143
+ round-trip needed to resolve it."""
144
+ provider = ReplicateProvider(api_token="test-token")
145
+ mock_client = MagicMock()
146
+ mock_client.predictions.create.return_value = FakePrediction()
147
+ provider._client = mock_client
148
+
149
+ step = _make_step(model="sczhou/codeformer:deadbeef1234")
150
+ provider.submit(step)
151
+
152
+ mock_client.models.get.assert_not_called()
153
+ mock_client.predictions.create.assert_called_once_with(
154
+ version="deadbeef1234",
155
+ input={"prompt": "a cat"},
156
+ )
157
+
158
+
159
+ def test_submit_caches_resolved_version_across_calls():
160
+ """The version resolution is cached per-slug so repeated submits (e.g.
161
+ across a batch run) don't re-issue ``models.get()`` per prediction."""
162
+ provider = ReplicateProvider(api_token="test-token")
163
+ mock_client = MagicMock()
164
+ mock_client.models.get.return_value = _make_model_with_version("cached-version")
165
+ mock_client.predictions.create.return_value = FakePrediction()
166
+ provider._client = mock_client
167
+
168
+ provider.submit(_make_step(model="sczhou/codeformer"))
169
+ provider.submit(_make_step(model="sczhou/codeformer"))
170
+
171
+ assert mock_client.models.get.call_count == 1
172
+ assert mock_client.predictions.create.call_count == 2
173
+
174
+
175
+ def test_submit_falls_back_to_model_path_for_official_versionless_model():
176
+ """Official Replicate models (e.g. ``black-forest-labs/flux-schnell``)
177
+ expose no version and are only runnable via ``predictions.create(model=…)``
178
+ (POST /v1/models/{owner}/{name}/predictions). When ``models.get`` reports
179
+ no ``latest_version``, submit() must fall back to the ``model=`` path with
180
+ the bare slug rather than raise — raising would break every official model,
181
+ including several this connector documents as supported (#109)."""
182
+ provider = ReplicateProvider(api_token="test-token")
183
+ mock_client = MagicMock()
184
+ mock_client.models.get.return_value = _make_model_with_version(None)
185
+ mock_client.predictions.create.return_value = FakePrediction()
186
+ provider._client = mock_client
187
+
188
+ result = provider.submit(_make_step(model="black-forest-labs/flux-schnell"))
189
+
190
+ mock_client.predictions.create.assert_called_once_with(
191
+ model="black-forest-labs/flux-schnell",
192
+ input={"prompt": "a cat"},
193
+ )
194
+ # A versionless official model must NOT be submitted with a version= kwarg.
195
+ assert "version" not in mock_client.predictions.create.call_args.kwargs
196
+ assert result == "pred-abc123"
197
+
198
+
199
+ def test_submit_caches_versionless_resolution_across_calls():
200
+ """The "official / no version" decision is cached per-slug just like a
201
+ resolved hash — a fan-out of submits over an official model issues one
202
+ ``models.get`` probe, not one per prediction."""
203
+ provider = ReplicateProvider(api_token="test-token")
204
+ mock_client = MagicMock()
205
+ mock_client.models.get.return_value = _make_model_with_version(None)
206
+ mock_client.predictions.create.return_value = FakePrediction()
207
+ provider._client = mock_client
208
+
209
+ provider.submit(_make_step(model="black-forest-labs/flux-schnell"))
210
+ provider.submit(_make_step(model="black-forest-labs/flux-schnell"))
211
+
212
+ assert mock_client.models.get.call_count == 1
213
+ assert mock_client.predictions.create.call_count == 2
214
+
215
+
216
+ def test_validate_model_seeds_version_cache_for_submit():
217
+ """``validate_model``'s per-slug probe and ``submit``'s version resolution
218
+ share one ``models.get()`` round-trip per slug — the pipeline preflight
219
+ phase already fetches the model, so submit() shouldn't fetch it again."""
220
+ provider = ReplicateProvider(api_token="test-token")
221
+ mock_client = MagicMock()
222
+ mock_client.models.get.return_value = _make_model_with_version("seeded-version")
223
+ mock_client.predictions.create.return_value = FakePrediction()
224
+ provider._client = mock_client
225
+
226
+ provider.validate_model("sczhou/codeformer")
227
+ provider.submit(_make_step(model="sczhou/codeformer"))
228
+
229
+ assert mock_client.models.get.call_count == 1
230
+ mock_client.predictions.create.assert_called_once_with(
231
+ version="seeded-version",
232
+ input={"prompt": "a cat"},
233
+ )
234
+
235
+
236
+ def test_validate_model_strips_inline_version_before_probe():
237
+ """A ``Pipeline.run()`` preflight validates ``step.model`` verbatim,
238
+ including any inline ``:version`` suffix. Replicate's model-existence
239
+ GET (``/v1/models/{owner}/{name}``) only accepts the bare slug — a
240
+ compound ``owner/name:hash`` key 404s even when the model exists, which
241
+ would otherwise abort the run with a false "model not found" before
242
+ ``submit()`` ever runs. ``validate_model`` must probe the bare slug."""
243
+ from genblaze_core.providers import ValidationOutcome
244
+
245
+ provider = ReplicateProvider(api_token="test-token")
246
+ mock_client = MagicMock()
247
+ mock_client.models.get.return_value = _make_model_with_version("resolved-hash")
248
+ provider._client = mock_client
249
+
250
+ result = provider.validate_model("sczhou/codeformer:deadbeef1234")
251
+
252
+ mock_client.models.get.assert_called_once_with("sczhou/codeformer")
253
+ assert result.outcome is ValidationOutcome.OK_AUTHORITATIVE
254
+
255
+
256
+ def test_validate_model_refresh_also_evicts_version_cache():
257
+ """``refresh=True`` is a request to re-check everything about a slug —
258
+ it must also drop any cached version resolution, not just the
259
+ validation-outcome cache, so a subsequent ``submit()`` re-resolves
260
+ ``latest_version`` instead of silently reusing a stale hash."""
261
+ provider = ReplicateProvider(api_token="test-token")
262
+ mock_client = MagicMock()
263
+ mock_client.models.get.return_value = _make_model_with_version("v1")
264
+ mock_client.predictions.create.return_value = FakePrediction()
265
+ provider._client = mock_client
266
+
267
+ provider.validate_model("sczhou/codeformer")
268
+ provider.submit(_make_step(model="sczhou/codeformer"))
269
+ assert mock_client.predictions.create.call_args.kwargs["version"] == "v1"
270
+
271
+ # Model owner publishes a new version; caller refreshes.
272
+ mock_client.models.get.return_value = _make_model_with_version("v2")
273
+ provider.validate_model("sczhou/codeformer", refresh=True)
274
+ provider.submit(_make_step(model="sczhou/codeformer"))
275
+
276
+ assert mock_client.predictions.create.call_args.kwargs["version"] == "v2"
277
+
278
+
105
279
  # --- poll tests ---
106
280
 
107
281
 
@@ -563,6 +737,10 @@ class TestReplicateCompliance(ProviderComplianceTests):
563
737
  def make_provider(self):
564
738
  provider = ReplicateProvider(api_token="test-token")
565
739
  mock_client = MagicMock()
740
+ # submit() resolves a version via models.get().latest_version.id
741
+ # before creating a prediction — stub it explicitly rather than
742
+ # relying on MagicMock's auto-vivified (but opaque) attribute chain.
743
+ mock_client.models.get.return_value = _make_model_with_version("compliance-version")
566
744
  mock_client.predictions.create.return_value = FakePrediction()
567
745
  mock_client.predictions.get.return_value = FakePrediction()
568
746
  provider._client = mock_client